Kundenkarten-Vertrag
Wenn Sie eine Person im Feedback-CRM öffnen, kann das Panel zeigen, wer dieser Kunde in Ihrem eigenen System ist: Tarif, Zahlungen, Produktnutzung, ein Link in Ihren Admin-Bereich. Sie betreiben einen HTTPS-Endpunkt, unser Server fragt dort mit einer signierten Anfrage nach der Karte, und das Panel zeigt die Antwort neben der Unterhaltung. Wir speichern die Karte nicht und führen ihre Aktionen nie aus.
1. So funktioniert es
Die Richtung ist dieselbe wie bei Custom Metrics: Unser Server ruft auf, Ihr Server antwortet. Sie öffnen eine bestätigte Person unter „Personen“ auf dem Tab „Feedback“ eines Projekts (siehe
Feedback-Vertrag, Abschnitte 1 und 9). Unser Server sendet einen signierten POST an Ihren Endpunkt, prüft die Antwort gegen das Schema unten und gibt dem Panel nur die Teile weiter, die er versteht. Der Browser erhält den Rohinhalt nie.
Vier Regeln, die keine Einstellung ändert:
- Nur bestätigte Personen. Wir fragen eine Karte nur ab, wenn Ihr Server die Identität der Person signiert hat (Signatur v1 des Feedback-Vertrags). Für eine nicht bestätigte Person gibt es keine Anfrage: Jeder kann eine beliebige E-Mail-Adresse eingeben, und die Karte eines fremden Kontos stünde dann neben ihrer Nachricht.
- Wir speichern die Karte nie. Nicht in der Datenbank, nicht im Cache, nicht in Logs. Jedes Mal, wenn Sie eine Person öffnen, fragt das Panel neu.
- Wir führen nie Zahlungen oder Aktionen aus. Eine Kartenaktion öffnet einen Link, kopiert einen Wert oder kopiert einen Text für Ihren KI-Support-Agenten. Das Panel sendet nichts und ruft nichts auf.
- Nur der Projektinhaber sieht sie, in einem Tarif mit Feedback-CRM. Personen, mit denen Sie ein Projekt teilen, sehen die Karte nie.
2. Einrichtung im Panel
In der Einrichtung des Projekts, Abschnitt „Feedback-Widget“, unter „Kundenkarte“ (nur Inhaber, Tarif mit CRM):
- Endpunkt-Adresse.
https://…, nur Port 443, eine öffentliche Adresse (Abschnitt 9). - Kartengeheimnis.
ccs_gefolgt von 32 zufälligen Bytes in base64url. Wir erzeugen es und zeigen es einmal an; danach sehen Sie nur die letzten 4 Zeichen. Wir speichern es verschlüsselt. - Geheimnis ersetzen. Das neue Geheimnis wird einmal angezeigt, und das bisherige gilt noch 24 Stunden. In diesem Zeitfenster trägt jede Anfrage zwei Signaturen (Abschnitt 4), damit Sie Zeit haben, das neue Geheimnis auf Ihrem Server einzuspielen.
- Testen. Sendet eine Anfrage mit
test: truefür eine Identität, die Sie eingeben (standardmäßigtestundtest@example.com). Das Panel zeigt „Karte erhalten, N Felder“ oder den HTTP-Status und bis zu 300 Bytes des Antwortinhalts. - Aktivieren. Der Schalter bleibt aus, bis Adresse und Geheimnis gesetzt sind.
3. Die Anfrage
POST https://support.example.com/extops/customer-card
Content-Type: application/json
Accept: application/json
User-Agent: ExtOpsPanel-CustomerCard/1.0 (+https://extops.dev/docs/customer-card)
X-ExtOps-Card-Timestamp: 1760371200
X-ExtOps-Card-Signature: v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
X-ExtOps-Request-Id: 6b0d1c9e-2f4a-4c55-9d0e-1f2a3b4c5d6e
{"schema_version":"1.0","project_id":"0c6a5c1e-6f1b-4b8e-9a52-3d1f0e7c2a10","person":{"user_id":"u_42","email":"anna@example.com","verified":true},"locale":"en","test":false}
| Feld | Typ | Bedeutung |
|---|---|---|
schema_version | "1.0" | Version des Anfrage- und Antwortschemas |
project_id | uuid | unsere ID des Projekts: Ein Endpunkt kann mehrere Projekte bedienen |
person.user_id | String bis 128 Zeichen oder null | die Nutzer-ID, die Ihr Server signiert hat (identify() plus Signatur v1) |
person.email | String bis 254 Zeichen oder null | die E-Mail-Adresse, die Ihr Server signiert hat |
person.verified | true | immer true, denn Karten werden nur für bestätigte Personen abgefragt; bleibt aus Kompatibilitätsgründen erhalten |
locale | en ru de es fr pt | die Sprache des Panels der lesenden Person, damit Ihre Beschriftungen in dieser Sprache zurückkommen können |
test | boolean | true für die Schaltfläche „Testen“; Sie können mit einer Demo-Karte antworten |
Wir senden weder den Namen der Person noch meta, den Text der Nachrichten oder unsere eigene ID der Person. Die Methode ist immer POST, damit E-Mail-Adresse und ID nicht in der Adresszeile, in Zugriffslogs und Proxys landen und der Inhalt von der Signatur abgedeckt ist.
Die gesamte Anfrage hat ein Timeout von 3 Sekunden (2 Sekunden für den Verbindungsaufbau). Es gibt keine Wiederholungen. Weiterleitungen werden nicht verfolgt: Eine 3xx-Antwort gilt als Fehler.
4. Signatur v1
signed = "v1|" + timestamp + "|" + METHOD + "|" + path_with_query + "|" + hex(SHA256(raw_body))
signature = hex(HMAC-SHA256(card_secret, signed))
header = "v1=" + signature
timestampist der HeaderX-ExtOps-Card-Timestamp, Unix-Zeit in Sekunden.METHODistPOST.path_with_queryist der Pfad Ihrer Adresse samt Query, wie in der HTTP-Anfragezeile (/extops/customer-card,/hooks/card?project=a); ein leerer Pfad ist/.hexin Kleinbuchstaben.card_secretist der vollständige String aus dem Panel (ccs_…) als UTF-8-Bytes.- Die Signatur deckt den Rohinhalt ab, vor jedem JSON-Parsing.
Ihr Endpunkt prüft, dass:
- beide Header vorhanden sind;
|now − timestamp| ≤ 300Sekunden;- mindestens eine Signatur im Header übereinstimmt, verglichen in konstanter Zeit.
Andernfalls antworten Sie mit 401; der Inhaber sieht „Karte nicht verfügbar“, wie bei jeder Antwort außer 200 oder 404. Eine abgefangene Anfrage, die innerhalb von 5 Minuten wiederholt wird, liefert dieselbe Karte derselben Person: Der Inhalt ist signiert, niemand kann die E-Mail-Adresse austauschen. Für einen strikten Schutz vor Wiederholungen speichern Sie jede X-ExtOps-Request-Id 5 Minuten lang und lehnen eine Wiederholung ab. Das liegt bei Ihnen.
Warum HMAC und kein Bearer-Token: Der Karten-Endpunkt liefert personenbezogene Daten zu einer E-Mail-Adresse. Ein Bearer-Token, das aus Proxy-, CDN- oder APM-Logs entweicht, würde es jedem erlauben, Adressen durchzuprobieren. Ein HMAC-Geheimnis geht nie über das Netz, und eine abgefangene Anfrage gilt 5 Minuten und nur für eine Person.
Wechsel des Geheimnisses
24 Stunden lang nach dem Ersetzen des Geheimnisses trägt der Header zwei durch Komma getrennte Signaturen: v1=<new secret>,v1=<previous secret>. Akzeptieren Sie die Anfrage, wenn eine davon übereinstimmt. Die Snippets unten tun das bereits.
Testvektoren
Prüfen Sie Ihre Implementierung mit diesen Werten. Die Tests unseres Servers legen dieselben Werte fest.
| Eingabe | Wert |
|---|---|
card_secret | ccs_test_vector_secret_not_for_production |
| bisheriges Geheimnis | ccs_previous_test_vector_secret |
timestamp | 1760371200 |
METHOD / path_with_query | POST / /extops/customer-card |
| Inhalt (eine Zeile, ohne abschließenden Zeilenumbruch) | {"schema_version":"1.0","project_id":"0c6a5c1e-6f1b-4b8e-9a52-3d1f0e7c2a10","person":{"user_id":"u_42","email":"anna@example.com","verified":true},"locale":"en","test":false} |
hex(SHA256(body)) | dcd0d48f2fb5d26f835b39e51076c79db02fc2bc4b98a364ab5c506959f45146 |
| Signatur mit dem Geheimnis | 035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283 |
| Signatur mit dem bisherigen Geheimnis | 1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
| Header während des Wechsels | v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283,v1=1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
Signatur prüfen
Node:
// Node (Express): needs the raw body, for example express.raw({ type: 'application/json' })
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
export function verifyCard(req, secret) {
const ts = req.get('X-ExtOps-Card-Timestamp');
const header = req.get('X-ExtOps-Card-Signature') || '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const bodyHash = createHash('sha256').update(req.body).digest('hex');
const signed = `v1|${ts}|${req.method}|${req.originalUrl}|${bodyHash}`;
const want = Buffer.from(createHmac('sha256', secret).update(signed).digest('hex'));
return header.split(',').some((part) => {
const got = Buffer.from(part.trim().replace(/^v1=/, ''));
return got.length === want.length && timingSafeEqual(got, want);
});
}
Go:
// Go: body is the raw request body, read before json.Unmarshal
func verifyCard(r *http.Request, body []byte, secret string) bool {
ts := r.Header.Get("X-ExtOps-Card-Timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
return false
}
sum := sha256.Sum256(body)
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "v1|%s|%s|%s|%s", ts, r.Method, r.URL.RequestURI(), hex.EncodeToString(sum[:]))
want := []byte(hex.EncodeToString(mac.Sum(nil)))
for _, part := range strings.Split(r.Header.Get("X-ExtOps-Card-Signature"), ",") {
got := []byte(strings.TrimPrefix(strings.TrimSpace(part), "v1="))
if hmac.Equal(got, want) {
return true
}
}
return false
}
Python:
# Python (Flask): request.get_data() returns the raw request body
import hashlib, hmac, time
def verify_card(request, secret: str) -> bool:
ts = request.headers.get("X-ExtOps-Card-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
body_hash = hashlib.sha256(request.get_data()).hexdigest()
path = request.full_path.rstrip("?")
signed = f"v1|{ts}|{request.method}|{path}|{body_hash}"
want = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
parts = request.headers.get("X-ExtOps-Card-Signature", "").split(",")
return any(hmac.compare_digest(p.strip().removeprefix("v1="), want) for p in parts)
5. Die Antwort: Status
| HTTP | Bedeutung | Was der Inhaber sieht |
|---|---|---|
200 mit einer Karte als JSON | die Karte | die Karte |
404 | Ihr System kennt diesen Kunden nicht | „Kunde in Ihrem System nicht gefunden“ |
alles andere, 3xx, ein Timeout, kein JSON, ein verletztes Limit | ein Fehler | „Karte nicht verfügbar“; die Unterhaltung funktioniert weiter |
Von einem Fehlerinhalt lesen wir höchstens 300 Bytes, und nur die Schaltfläche „Testen“ zeigt sie dem Inhaber.
6. Die Antwort: die Karte
Card {
schema_version: "1.0" required, major 1
title?: string ≤ 80 card heading
subtitle?: string ≤ 160
status?: Status overall customer status
sections: Section[] 1..8 required
actions?: Action[] ≤ 6 actions of the whole card
}
Section { id: slug ≤ 40, title?: string ≤ 60, fields: Field[] 1..20, actions?: Action[] ≤ 6 }
Field { id: slug ≤ 40, label: string ≤ 60, type: FieldType, value: <by type>, ref?: Ref, hint?: string ≤ 160 }
FieldType:
text value: string ≤ 500
money value: { amount_minor: integer, currency: ISO 4217, three letters }
date value: "YYYY-MM-DD" | RFC 3339
status value: Status
link value: { url: https-URL ≤ 2048, text?: string ≤ 80 }
count value: integer ≥ 0
Status { label: string ≤ 40, tone: "ok" | "warn" | "bad" | "neutral" }
Ref { kind: "payment" | "invoice" | "subscription" | "customer", id: string ≤ 128, provider?: string ≤ 32 }
Action:
link { type: "link", label ≤ 40, url: https-URL ≤ 2048 }
copy { type: "copy", label ≤ 40, value: string ≤ 500 }
prompt { type: "prompt", label ≤ 40, prompt: string ≤ 4000 }
| Feldtyp | Wert |
|---|---|
text | ein String bis 500 Zeichen |
money | { "amount_minor": 4900, "currency": "USD" }: der Betrag in der kleinsten Währungseinheit (Cent) und ein ISO-4217-Code |
date | "YYYY-MM-DD" oder eine Zeit nach RFC 3339 |
status | { "label": "Active", "tone": "ok" } |
link | { "url": "https://…", "text": "Receipt" }; der Text ist optional |
count | eine ganze Zahl, 0 oder größer |
- Ein Status-Ton ist einer von
ok,warn,bad,neutral. - Eine
ref-Art ist eine vonpayment,invoice,subscription,customer. - Ein Aktionstyp ist einer von
link,copy,prompt(Abschnitt 7). - Ein Slug ist
[a-z0-9_-], 1 bis 40 Zeichen. String-Längen zählen Unicode-Zeichen. - Eine ganze Karte hat höchstens 40 Felder und 12 Aktionen, der Inhalt ist höchstens 64 KiB groß, mit
Content-Type: application/jsonin UTF-8. currencydarf in beliebiger Schreibweise kommen; der Server wandelt in Großbuchstaben um.refist ein maschinenlesbarer Hinweis, worum es im Feld geht: eine Zahlung, eine Rechnung, ein Abonnement oder ein Kunde. Das Panel zeigt ihn noch nicht an; künftige Vorlagen werden ihn nutzen. Einrefmit unbekannter Art wird verworfen, das Feld bleibt.
Kompatibilität und was verworfen wird
Die Karte zeichnet ein gemeinsamer Renderer, daher prüft der Server Ihre Antwort nach den Regeln unten und gibt dem Panel nur bekannte Teile weiter. Der Browser erhält den Rohinhalt nie.
- Ein unbekannter Feldtyp, Aktionstyp oder Ton: Dieses Element wird verworfen, der Rest wird angezeigt.
- Ein Element, das ein Limit verletzt (eine Länge, ein leerer Pflicht-String, ein Wert vom falschen Typ), wird ganz verworfen, nicht gekürzt. Ein optionaler String über seinem Limit (
title,subtitle, ein Abschnittstitel,hint) oder ein ungültigesrefwird allein verworfen, und das Element bleibt. Abschnitte, Felder oder Aktionen über dem Mengenlimit werden vom Ende her verworfen. Steuerzeichen werden aus Strings entfernt, Leerzeichen an beiden Enden abgeschnitten. - Ein Abschnitt ohne gültiges Feld wird verworfen.
- Kein
schema_versionmit Hauptversion 1, kein gültiger Abschnitt, ein Inhalt über 64 KiB oder ein Inhalt, der kein JSON ist: Die ganze Karte ist nicht verfügbar. - Links nur mit
https:. Einlink-Feld mit einer anderen Adresse wird als Text angezeigt (sein Text oder die Adresse), und einelink-Aktion mit einer solchen wird verworfen.javascript:,data:und alles andere werden nie zu Links. - Unbekannte Objektschlüssel werden ignoriert, Sie können sie also schon vorab hinzufügen.
Beispiel: ein SaaS-Produkt
{
"schema_version": "1.0",
"title": "Anna Petrova",
"subtitle": "Customer since March 2025",
"status": { "label": "Active", "tone": "ok" },
"sections": [
{
"id": "plan",
"title": "Plan",
"fields": [
{ "id": "plan", "label": "Plan", "type": "text", "value": "Team, yearly" },
{ "id": "seats", "label": "Seats", "type": "count", "value": 12 },
{ "id": "mrr", "label": "MRR", "type": "money", "value": { "amount_minor": 4900, "currency": "USD" } },
{ "id": "renews", "label": "Renews", "type": "date", "value": "2026-03-14" }
]
},
{
"id": "usage",
"title": "Product",
"fields": [
{ "id": "last_seen", "label": "Last active", "type": "date", "value": "2026-10-12T18:04:00Z" },
{ "id": "errors", "label": "Failed exports, 7 days", "type": "count", "value": 3 },
{ "id": "sync", "label": "Sync", "type": "status", "value": { "label": "Paused", "tone": "warn" } }
],
"actions": [
{ "type": "link", "label": "Open in admin", "url": "https://admin.example.com/users/u_42" }
]
}
],
"actions": [
{ "type": "copy", "label": "Copy user id", "value": "u_42" },
{
"type": "prompt",
"label": "Ask the agent to check exports",
"prompt": "Customer u_42 reports failed exports. Check the last 7 days of export jobs for this user and summarise the errors. Do not change anything."
}
]
}
Beispiel: ein Abonnement und Zahlungen
Ein Inhaber, der Zahlungen über EasyPay annimmt, kann das Abonnement und die Zahlungen mit ref kennzeichnen. Fertige Vorlagen für Abrechnungskarten kommen später; vorerst ist das eine gewöhnliche Karte.
{
"schema_version": "1.0",
"title": "Studio, monthly",
"status": { "label": "Cancelled", "tone": "warn" },
"sections": [
{
"id": "subscription",
"title": "Subscription",
"fields": [
{ "id": "status", "label": "Status", "type": "status", "value": { "label": "Ends on 14 Nov", "tone": "warn" },
"ref": { "kind": "subscription", "id": "sub_1Q2w3E4r", "provider": "easypay" } },
{ "id": "price", "label": "Price", "type": "money", "value": { "amount_minor": 1900, "currency": "USD" } },
{ "id": "ends", "label": "Access until", "type": "date", "value": "2026-11-14" }
]
},
{
"id": "payments",
"title": "Recent payments",
"fields": [
{ "id": "p1", "label": "13 Oct, paid", "type": "money", "value": { "amount_minor": 1900, "currency": "USD" },
"ref": { "kind": "payment", "id": "pi_3Pq9Lm", "provider": "easypay" }, "hint": "Invoice 4F2A-0007" },
{ "id": "inv1", "label": "Invoice", "type": "text", "value": "4F2A-0007",
"ref": { "kind": "invoice", "id": "in_1Pq9Lm", "provider": "easypay" } },
{ "id": "r1", "label": "Receipt", "type": "link", "value": { "url": "https://pay.stripe.com/receipts/example", "text": "Receipt" } },
{ "id": "cust", "label": "Customer", "type": "text", "value": "cus_Q2w3E4r",
"ref": { "kind": "customer", "id": "cus_Q2w3E4r", "provider": "easypay" } }
]
}
],
"actions": [
{ "type": "copy", "label": "Copy subscription id", "value": "sub_1Q2w3E4r" }
]
}
Am einfachsten bauen Sie einen solchen Endpunkt auf Ihrer eigenen Datenbank auf, die der EasyPay-Webhook befüllt: die Abonnement-ID, ihr Status, die Kündigungs- und Enddaten sowie Zahlungen mit ihrem payment_intent und ihrer Rechnung. Legen Sie keinen EasyPay-Partnerschlüssel mit vollen Rechten in den Karten-Endpunkt.
7. Aktionen
type | Was das Panel tut |
|---|---|
link | öffnet eine https:-Adresse in einem neuen Tab (rel="noopener noreferrer") |
copy | kopiert value in die Zwischenablage |
prompt | zeigt den Text und kopiert ihn für Ihren KI-Support-Agenten |
Ein Prompt ist Daten, kein Befehl: Das Panel kopiert den Text nur und führt ihn nie aus oder sendet ihn irgendwohin. Fügen Sie keinen Nachrichtentext eines Besuchers in einen Prompt ein. Das wäre eine Prompt-Injection in einen Agenten, der womöglich Rechte über Geld hat. Schreiben Sie in den Prompt, dass jede Aktion mit Geld von einem Menschen bestätigt werden muss.
8. Was in die Karte gehört
Nur der Projektinhaber sieht die Karte, aber alles, was Sie zurückgeben, landet in seinem Browser. Beschränken Sie sich auf das, was der Support braucht: Tarif, Abonnementstatus, Zahlungsbeträge und -daten, Produktzähler, Links in Ihren Admin-Bereich. Geben Sie keine Kartendaten, Passwörter, Tokens, Postanschriften oder Daten anderer Personen zurück. E-Mail-Adresse und Namen hat der Inhaber bereits aus der Nachricht.
9. Sicherheit auf unserer Seite
- Adresse. Nur
https, nur Port 443. Der Host darf nichtlocalhostsein, kein Name unter.localoder.internalund kein IP-Literal eines privaten Netzes. Wir prüfen das beim Speichern der Einstellungen. - Verbindung. Jede Adresse, mit der sich ein Socket tatsächlich verbindet, wird beim Verbindungsaufbau geprüft, das verhindert DNS-Rebinding. Abgelehnt werden Loopback, private Netze (RFC 1918 und ULA), Link-Local einschließlich
169.254.169.254, unspezifizierte und Multicast-Adressen, CGNAT100.64.0.0/10,0.0.0.0/8,192.0.0.0/24,198.18.0.0/15,240.0.0.0/4und NAT6464:ff9b::/96mit einer privaten Adresse darin. Proxys aus der Umgebung werden nicht verwendet, Weiterleitungen nicht verfolgt. - Last. Höchstens 8 gleichzeitige Kartenanfragen pro Server (darüber hinaus ist die Karte sofort nicht verfügbar), höchstens 60 Anfragen pro Minute und Projekt, und die Schaltfläche „Testen“ 10-mal pro Stunde und Projekt.
- Logs. Wir protokollieren die Projekt-ID, den Host des Endpunkts, den HTTP-Status, die Dauer und die Fehlerklasse. Nie den Inhalt von Anfrage oder Antwort, E-Mail-Adressen, Nutzer-IDs, den Pfad mit Query oder Signaturen.
- Darstellung. Nur reiner Text, kein HTML und kein Markdown; Links nur mit
https:. - Ausschalter. Wir können Kartenanfragen für den gesamten Dienst abschalten, etwa während eines Vorfalls. Dann verlässt keine Kartenanfrage unsere Server, Karte und Schaltfläche „Testen“ sind im Panel nicht verfügbar, und Ihre Einstellungen bleiben erhalten.
10. Tarife
Die Kundenkarte gehört zum Feedback-CRM in Indie, Studio und Fleet. Es zählt der Tarif Ihres Kontos, und eine Testphase schaltet sie nicht frei. Nach einem Downgrade bleiben Ihre Einstellungen erhalten, und die Karte wird nicht abgefragt, bis der Tarif zurückkommt.
11. Der maschinenlesbare Vertrag
Die formale Beschreibung des Endpunkts, den Sie betreiben, der Anfrage, des Kartenschemas und seiner Aufzählungen ist die OpenAPI-3.1-Datei; der Endpunkt steht unter webhooks, weil wir Sie aufrufen. Sie trägt dieselbe Vertragsversion (1.0.0), die erste Version dieses Vertrags.