Customer card contract
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.
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:
- Verified people only. We ask for a card only when your server signed the person's identity (signature v1 of the feedback contract). An unverified person gets no request: anyone can type any e-mail, and the card of someone else's account would end up next to their message.
- We never store the card. Not in the database, not in a cache, not in logs. Every time you open a person, the panel asks again.
- We never run payments or actions. A card action opens a link, copies a value or copies a text for your support AI agent. The panel sends nothing and calls nothing.
- Only the project owner sees it, on a plan with the feedback CRM. People you share a project with never see the card.
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):
- Endpoint address.
https://…, port 443 only, a public address (section 9). - 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. - 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.
- Test. Sends a request with
test: truefor an identity you enter (by defaulttestandtest@example.com). The panel shows "Card received, N fields", or the HTTP status and up to 300 bytes of the response body. - 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}
| Field | Type | Meaning |
|---|---|---|
schema_version | "1.0" | version of the request and answer schema |
project_id | uuid | our id of the project: one endpoint can serve several projects |
person.user_id | string up to 128, or null | the user id your server signed (identify() plus signature v1) |
person.email | string up to 254, or null | the e-mail your server signed |
person.verified | true | always true, because cards are requested for verified people only; kept for compatibility |
locale | en ru de es fr pt | the language of the reader's panel, so your labels can come back in it |
test | boolean | true 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
timestampis theX-ExtOps-Card-Timestampheader, Unix time in seconds.METHODisPOST.path_with_queryis the path of your address with its query, as in the HTTP request line (/extops/customer-card,/hooks/card?project=a); an empty path is/.hexis lowercase.card_secretis the whole string from the panel (ccs_…) as UTF-8 bytes.- The signature covers the raw body, before any JSON parsing.
Your endpoint checks that:
- both headers are present;
|now − timestamp| ≤ 300seconds;- 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.
| Input | Value |
|---|---|
card_secret | ccs_test_vector_secret_not_for_production |
| previous secret | ccs_previous_test_vector_secret |
timestamp | 1760371200 |
METHOD / path_with_query | POST / /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 secret | 035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283 |
| signature with the previous secret | 1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
| header during rotation | v1=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
| HTTP | Meaning | What the owner sees |
|---|---|---|
200 with a card in JSON | the card | the card |
404 | your system has no such customer | "Customer not found in your system" |
anything else, 3xx, a timeout, not JSON, a broken limit | a 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 type | Value |
|---|---|
text | a 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 |
count | an integer, 0 or more |
- A status tone is one of
ok,warn,bad,neutral. - A
refkind is one ofpayment,invoice,subscription,customer. - An action type is one of
link,copy,prompt(section 7). - A slug is
[a-z0-9_-], 1 to 40 characters. String lengths count Unicode characters. - A whole card has at most 40 fields and 12 actions, the body is at most 64 KiB, with
Content-Type: application/jsonin UTF-8. currencymay come in any case; the server upper-cases it.refis a machine hint of what the field is about: a payment, an invoice, a subscription or a customer. The panel does not show it yet; future presets will use it. Arefwith an unknown kind is dropped and the field stays.
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.
- An unknown field type, action type or tone: that element is dropped, the rest is drawn.
- An element that breaks a limit (a length, an empty required string, a value of the wrong
type) is dropped whole, not cut. An optional string over its limit (
title,subtitle, a section title,hint) or an invalidrefis dropped alone, and the element stays. Sections, fields or actions over the count limit are dropped from the end. Control characters are removed from strings, and spaces at both ends are trimmed. - A section with no valid field is dropped.
- No
schema_versionwith major version 1, no valid section, a body over 64 KiB or a body that is not JSON: the whole card is unavailable. - Links are
https:only. Alinkfield with another address is shown as text (its text or the address), and alinkaction with one is dropped.javascript:,data:and the rest never become links. - Unknown object keys are ignored, so you can add them ahead of time.
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
type | What the panel does |
|---|---|
link | opens an https: address in a new tab (rel="noopener noreferrer") |
copy | copies value to the clipboard |
prompt | shows 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
- Address.
httpsonly, port 443 only. The host cannot belocalhost, a.localor.internalname, or an IP literal of a private network. We check it when you save the settings. - Connection. Every address a socket actually connects to is checked at connect
time, which stops DNS rebinding. Loopback, private networks (RFC 1918 and ULA), link-local
including
169.254.169.254, unspecified and multicast addresses, CGNAT100.64.0.0/10,0.0.0.0/8,192.0.0.0/24,198.18.0.0/15,240.0.0.0/4and NAT6464:ff9b::/96with a private address inside are refused. Proxies from the environment are not used, redirects are not followed. - Load. At most 8 card requests at a time per server (above that the card is unavailable at once), at most 60 requests a minute per project, and the Test button 10 times an hour per project.
- Logs. We log the project id, the endpoint host, the HTTP status, the duration and the class of error. Never the request or response body, e-mail addresses, user ids, the path with its query or signatures.
- Rendering. Plain text only, no HTML and no markdown; links are
https:only. - Off switch. We can switch card requests off for the whole service, for example during an incident. Then no card request leaves our servers, the card and the Test button are not available in the panel, and your settings stay as they are.
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.