Ext Ops Panel
Dashboard öffnen

Start/Doks/Kundenkarte

Kundenkarten-Vertrag

Vertragsversion 1.0.0 · Teil des Feedback-CRM in Indie, Studio und Fleet

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.

↓ OpenAPI 3.1 (YAML) herunterladen /customer-card-openapi.yaml · info.version 1.0.0

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:

2. Einrichtung im Panel

In der Einrichtung des Projekts, Abschnitt „Feedback-Widget“, unter „Kundenkarte“ (nur Inhaber, Tarif mit CRM):

  1. Endpunkt-Adresse. https://…, nur Port 443, eine öffentliche Adresse (Abschnitt 9).
  2. 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.
  3. 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.
  4. Testen. Sendet eine Anfrage mit test: true für eine Identität, die Sie eingeben (standardmäßig test und test@example.com). Das Panel zeigt „Karte erhalten, N Felder“ oder den HTTP-Status und bis zu 300 Bytes des Antwortinhalts.
  5. 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}
FeldTypBedeutung
schema_version"1.0"Version des Anfrage- und Antwortschemas
project_iduuidunsere ID des Projekts: Ein Endpunkt kann mehrere Projekte bedienen
person.user_idString bis 128 Zeichen oder nulldie Nutzer-ID, die Ihr Server signiert hat (identify() plus Signatur v1)
person.emailString bis 254 Zeichen oder nulldie E-Mail-Adresse, die Ihr Server signiert hat
person.verifiedtrueimmer true, denn Karten werden nur für bestätigte Personen abgefragt; bleibt aus Kompatibilitätsgründen erhalten
localeen ru de es fr ptdie Sprache des Panels der lesenden Person, damit Ihre Beschriftungen in dieser Sprache zurückkommen können
testbooleantrue 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

Ihr Endpunkt prüft, dass:

  1. beide Header vorhanden sind;
  2. |now − timestamp| ≤ 300 Sekunden;
  3. 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.

EingabeWert
card_secretccs_test_vector_secret_not_for_production
bisheriges Geheimnisccs_previous_test_vector_secret
timestamp1760371200
METHOD / path_with_queryPOST / /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 Geheimnis035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
Signatur mit dem bisherigen Geheimnis1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd
Header während des Wechselsv1=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

HTTPBedeutungWas der Inhaber sieht
200 mit einer Karte als JSONdie Kartedie Karte
404Ihr System kennt diesen Kunden nicht„Kunde in Ihrem System nicht gefunden“
alles andere, 3xx, ein Timeout, kein JSON, ein verletztes Limitein 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 }
FeldtypWert
textein 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
counteine ganze Zahl, 0 oder größer

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.

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

typeWas das Panel tut
linköffnet eine https:-Adresse in einem neuen Tab (rel="noopener noreferrer")
copykopiert value in die Zwischenablage
promptzeigt 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

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.

↓ OpenAPI 3.1 (YAML) herunterladen /customer-card-openapi.yaml