Ext Ops Panel
Open dashboard

Home/Docs/Customer card

Customer card contract

Contract version 1.0.0 · part of the feedback CRM on Indie, Studio and Fleet

When you open a person in the feedback CRM, the panel can show who that customer is in your own system: plan, payments, product usage, a link to your admin. You host one HTTPS endpoint, our server asks it for the card with a signed request, and the panel draws the answer next to the conversation. We do not store the card and never run its actions.

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

1. How it works

The direction is the same as for custom metrics: our server calls, your server answers. You open a verified person in People on a project's Feedback tab (see the feedback contract, sections 1 and 9). Our server sends one signed POST to your endpoint, checks the answer against the schema below and passes the panel only the parts it understands. The browser never receives the raw body.

Four rules that no setting changes:

2. Set it up in the panel

In the project's Setup, in the Feedback widget section, under Customer card (owner only, a plan with the CRM):

  1. Endpoint address. https://…, port 443 only, a public address (section 9).
  2. Card secret. ccs_ followed by 32 random bytes in base64url. We issue it and show it once; after that you see only its last 4 characters. We keep it encrypted.
  3. Replace secret. The new secret is shown once and the previous one keeps working for 24 hours. During that window every request carries two signatures (section 4), which gives you time to roll the new secret out to your server.
  4. Test. Sends a request with test: true for an identity you enter (by default test and test@example.com). The panel shows "Card received, N fields", or the HTTP status and up to 300 bytes of the response body.
  5. Enable. The switch stays off until both the address and the secret are set.

3. The request

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}
FieldTypeMeaning
schema_version"1.0"version of the request and answer schema
project_iduuidour id of the project: one endpoint can serve several projects
person.user_idstring up to 128, or nullthe user id your server signed (identify() plus signature v1)
person.emailstring up to 254, or nullthe e-mail your server signed
person.verifiedtruealways true, because cards are requested for verified people only; kept for compatibility
localeen ru de es fr ptthe language of the reader's panel, so your labels can come back in it
testbooleantrue for the Test button; you may answer with a demo card

We do not send the person's name, meta, the text of messages or our own id of the person. The method is always POST, so the e-mail and the id stay out of the address bar, access logs and proxies, and the body is covered by the signature.

The whole request has a 3-second timeout (2 seconds to connect). There are no retries. Redirects are not followed: a 3xx answer counts as a failure.

4. Signature v1

signed    = "v1|" + timestamp + "|" + METHOD + "|" + path_with_query + "|" + hex(SHA256(raw_body))
signature = hex(HMAC-SHA256(card_secret, signed))
header    = "v1=" + signature

Your endpoint checks that:

  1. both headers are present;
  2. |now − timestamp| ≤ 300 seconds;
  3. at least one signature in the header matches, compared in constant time.

Otherwise answer 401; the owner sees "Card unavailable", as for any answer other than 200 or 404. A captured request replayed within 5 minutes returns the same card of the same person: the body is signed, so nobody can swap the e-mail. For strict replay protection, keep each X-ExtOps-Request-Id for 5 minutes and refuse a repeat. That part is up to you.

Why HMAC and not a bearer token: the card endpoint returns personal data for an e-mail. A bearer token leaked from proxy, CDN or APM logs would let anyone walk through addresses. An HMAC secret never travels over the network, and a captured request is valid for 5 minutes and for one person only.

Secret rotation

For 24 hours after you replace the secret, the header carries two signatures separated by a comma: v1=<new secret>,v1=<previous secret>. Accept the request when any of them matches. The snippets below already do.

Test vectors

Use these values to check your implementation. The tests of our server pin the same values.

InputValue
card_secretccs_test_vector_secret_not_for_production
previous secretccs_previous_test_vector_secret
timestamp1760371200
METHOD / path_with_queryPOST / /extops/customer-card
body (one line, no trailing newline){"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
signature with the secret035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
signature with the previous secret1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd
header during rotationv1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283,v1=1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd

Check the signature

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. The answer: statuses

HTTPMeaningWhat the owner sees
200 with a card in JSONthe cardthe card
404your system has no such customer"Customer not found in your system"
anything else, 3xx, a timeout, not JSON, a broken limita failure"Card unavailable"; the conversation keeps working

We read at most 300 bytes of an error body, and only the Test button shows them to the owner.

6. The answer: the card

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 }
Field typeValue
texta string up to 500 characters
money{ "amount_minor": 4900, "currency": "USD" }: the amount in minor units (cents) and an ISO 4217 code
date"YYYY-MM-DD" or an RFC 3339 time
status{ "label": "Active", "tone": "ok" }
link{ "url": "https://…", "text": "Receipt" }; the text is optional
countan integer, 0 or more

Compatibility and what gets dropped

The card is drawn by one shared renderer, so the server parses your answer by the rules below and passes the panel only known parts. The browser never receives the raw body.

Example: a SaaS product

{
  "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."
    }
  ]
}

Example: a subscription and payments

An owner who takes payments through EasyPay can mark the subscription and the payments with ref. Ready-made presets for billing cards will come later; for now this is an ordinary card.

{
  "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" }
  ]
}

The simplest way to build such an endpoint is from your own database that the EasyPay webhook fills: the subscription id, its status, the cancel and end dates, and payments with their payment_intent and invoice. Do not put an EasyPay partner key with full rights into the card endpoint.

7. Actions

typeWhat the panel does
linkopens an https: address in a new tab (rel="noopener noreferrer")
copycopies value to the clipboard
promptshows the text and copies it for your support AI agent

A prompt is data, not a command: the panel only copies the text and never runs it or sends it anywhere. Do not paste a visitor's message text into a prompt. That is a prompt injection into an agent that may have rights over money. Write in the prompt that a person must confirm any action with money.

8. What to put in the card

Only the project owner sees the card, but everything you return goes to their browser. Keep it to what support needs: the plan, the subscription status, payment amounts and dates, product counters, links to your admin. Do not return card data, passwords, tokens, postal addresses or data about other people. The owner already has the e-mail and the name from the message.

9. Security on our side

10. Plans

The customer card is part of the feedback CRM on Indie, Studio and Fleet. The plan of your account is what counts, and a trial does not unlock it. After a downgrade your settings are kept and the card is not requested until the plan comes back.

11. The machine-readable contract

The formal description of the endpoint you host, the request, the card schema and its enums is the OpenAPI 3.1 file; the endpoint sits under webhooks, because we call you. It carries the same contract version (1.0.0), the first version of this contract.

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