Contrato do cartão do cliente
Quando você abre uma pessoa no CRM de feedback, o painel pode mostrar quem é esse cliente no seu próprio sistema: plano, pagamentos, uso do produto, um link para o seu admin. Você hospeda um endpoint HTTPS, o nosso servidor pede a ele o cartão com uma requisição assinada, e o painel mostra a resposta ao lado da conversa. Não armazenamos o cartão e nunca executamos as ações dele.
1. Como funciona
A direção é a mesma das métricas personalizadas: o nosso servidor chama, o seu responde. Você abre uma pessoa verificada em "Pessoas" na aba "Feedback" de um projeto (veja o
contrato de feedback, seções 1 e 9). O nosso servidor envia um POST assinado ao seu endpoint, confere a resposta com o esquema abaixo e repassa ao painel só as partes que entende. O navegador nunca recebe o corpo bruto.
Quatro regras que nenhuma configuração muda:
- Só pessoas verificadas. Pedimos um cartão só quando o seu servidor assinou a identidade da pessoa (assinatura v1 do contrato de feedback). Uma pessoa não verificada não gera requisição: qualquer um pode digitar qualquer e-mail, e o cartão da conta de outra pessoa acabaria ao lado da mensagem dele.
- Nunca armazenamos o cartão. Nem no banco de dados, nem em cache, nem em logs. Toda vez que você abre uma pessoa, o painel pergunta de novo.
- Nunca executamos pagamentos nem ações. Uma ação do cartão abre um link, copia um valor ou copia um texto para o seu agente de IA de suporte. O painel não envia nada e não chama nada.
- Só o proprietário do projeto vê o cartão, em um plano com o CRM de feedback. As pessoas com quem você compartilha um projeto nunca veem o cartão.
2. Configuração no painel
Na configuração do projeto, seção "Widget de feedback", parte "Cartão do cliente" (só o proprietário, plano com o CRM):
- Endereço do endpoint.
https://…, só a porta 443, um endereço público (seção 9). - Segredo do cartão.
ccs_seguido de 32 bytes aleatórios em base64url. Nós o emitimos e mostramos uma vez; depois você vê só os últimos 4 caracteres. Guardamos o segredo criptografado. - Substituir segredo. O novo segredo aparece uma vez e o anterior continua valendo por 24 horas. Nesse período cada requisição leva duas assinaturas (seção 4), o que dá tempo de colocar o novo segredo no seu servidor.
- Testar. Envia uma requisição com
test: truepara uma identidade que você digita (por padrãotestetest@example.com). O painel mostra "Cartão recebido, N campos", ou o status HTTP e até 300 bytes do corpo da resposta. - Ativar. A chave só liga quando o endereço e o segredo estão definidos.
3. A requisição
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}
| Campo | Tipo | Significado |
|---|---|---|
schema_version | "1.0" | versão do esquema da requisição e da resposta |
project_id | uuid | o nosso id do projeto: um endpoint pode atender vários projetos |
person.user_id | string de até 128 caracteres, ou null | o id de usuário que o seu servidor assinou (identify() mais a assinatura v1) |
person.email | string de até 254 caracteres, ou null | o e-mail que o seu servidor assinou |
person.verified | true | sempre true, porque os cartões são pedidos só para pessoas verificadas; mantido por compatibilidade |
locale | en ru de es fr pt | o idioma do painel de quem lê, para que os seus rótulos possam voltar nesse idioma |
test | boolean | true para o botão "Testar"; você pode responder com um cartão de demonstração |
Não enviamos o nome da pessoa, meta, o texto das mensagens nem o nosso próprio id da pessoa. O método é sempre POST, assim o e-mail e o id ficam fora da barra de endereço, dos logs de acesso e dos proxies, e o corpo fica coberto pela assinatura.
A requisição inteira tem um tempo limite de 3 segundos (2 segundos para conectar). Não há novas tentativas. Redirecionamentos não são seguidos: uma resposta 3xx conta como falha.
4. Assinatura v1
signed = "v1|" + timestamp + "|" + METHOD + "|" + path_with_query + "|" + hex(SHA256(raw_body))
signature = hex(HMAC-SHA256(card_secret, signed))
header = "v1=" + signature
timestampé o cabeçalhoX-ExtOps-Card-Timestamp, tempo Unix em segundos.METHODéPOST.path_with_queryé o caminho do seu endereço com a query, como na linha de requisição HTTP (/extops/customer-card,/hooks/card?project=a); um caminho vazio é/.hexem minúsculas.card_secreté a string inteira do painel (ccs_…) como bytes UTF-8.- A assinatura cobre o corpo bruto, antes de qualquer parsing de JSON.
O seu endpoint verifica que:
- os dois cabeçalhos estão presentes;
|now − timestamp| ≤ 300segundos;- pelo menos uma assinatura do cabeçalho confere, comparada em tempo constante.
Caso contrário, responda 401; o proprietário vê "Cartão indisponível", como em qualquer resposta diferente de 200 ou 404. Uma requisição interceptada e repetida em até 5 minutos retorna o mesmo cartão da mesma pessoa: o corpo é assinado, então ninguém consegue trocar o e-mail. Para uma proteção rígida contra repetição, guarde cada X-ExtOps-Request-Id por 5 minutos e recuse a repetição. Essa parte fica a seu critério.
Por que HMAC e não um token bearer: o endpoint do cartão retorna dados pessoais a partir de um e-mail. Um token bearer vazado de logs de proxy, CDN ou APM deixaria qualquer um percorrer endereços. Um segredo HMAC nunca trafega pela rede, e uma requisição interceptada vale por 5 minutos e para uma só pessoa.
Troca do segredo
Durante as 24 horas depois que você substitui o segredo, o cabeçalho leva duas assinaturas separadas por vírgula: v1=<new secret>,v1=<previous secret>. Aceite a requisição quando qualquer uma delas conferir. Os trechos de código abaixo já fazem isso.
Vetores de teste
Use estes valores para conferir a sua implementação. Os testes do nosso servidor fixam os mesmos valores.
| Entrada | Valor |
|---|---|
card_secret | ccs_test_vector_secret_not_for_production |
| segredo anterior | ccs_previous_test_vector_secret |
timestamp | 1760371200 |
METHOD / path_with_query | POST / /extops/customer-card |
| corpo (uma linha, sem quebra de linha no final) | {"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 |
| assinatura com o segredo | 035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283 |
| assinatura com o segredo anterior | 1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
| cabeçalho durante a troca | v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283,v1=1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
Conferir a assinatura
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. A resposta: status
| HTTP | Significado | O que o proprietário vê |
|---|---|---|
200 com um cartão em JSON | o cartão | o cartão |
404 | o seu sistema não tem esse cliente | "Cliente não encontrado no seu sistema" |
qualquer outra coisa, 3xx, tempo limite esgotado, algo que não é JSON, um limite violado | uma falha | "Cartão indisponível"; a conversa continua funcionando |
De um corpo de erro lemos no máximo 300 bytes, e só o botão "Testar" os mostra ao proprietário.
6. A resposta: o cartão
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 }
| Tipo de campo | Valor |
|---|---|
text | uma string de até 500 caracteres |
money | { "amount_minor": 4900, "currency": "USD" }: o valor em unidades menores (centavos) e um código ISO 4217 |
date | "YYYY-MM-DD" ou um horário RFC 3339 |
status | { "label": "Active", "tone": "ok" } |
link | { "url": "https://…", "text": "Receipt" }; o texto é opcional |
count | um número inteiro, 0 ou mais |
- Um tom de status é um de
ok,warn,bad,neutral. - Um tipo de
refé um depayment,invoice,subscription,customer. - Um tipo de ação é um de
link,copy,prompt(seção 7). - Um slug é
[a-z0-9_-], de 1 a 40 caracteres. Os comprimentos de string contam caracteres Unicode. - Um cartão inteiro tem no máximo 40 campos e 12 ações, o corpo tem no máximo 64 KiB, com
Content-Type: application/jsonem UTF-8. currencypode vir em maiúsculas ou minúsculas; o servidor converte para maiúsculas.refé uma dica para máquinas sobre o assunto do campo: um pagamento, uma fatura, uma assinatura ou um cliente. O painel ainda não mostra isso; presets futuros vão usar. Umrefcom tipo desconhecido é descartado e o campo fica.
Compatibilidade e o que é descartado
O cartão é desenhado por um renderizador comum, então o servidor analisa a sua resposta pelas regras abaixo e repassa ao painel só as partes conhecidas. O navegador nunca recebe o corpo bruto.
- Um tipo de campo, tipo de ação ou tom desconhecido: esse elemento é descartado, o resto é exibido.
- Um elemento que viola um limite (um comprimento, uma string obrigatória vazia, um valor do tipo errado) é descartado inteiro, não cortado. Uma string opcional acima do limite (
title,subtitle, o título de uma seção,hint) ou umrefinválido é descartado sozinho, e o elemento fica. Seções, campos ou ações acima do limite de quantidade são descartados a partir do fim. Caracteres de controle são removidos das strings, e os espaços nas duas pontas são aparados. - Uma seção sem nenhum campo válido é descartada.
- Sem
schema_versioncom versão principal 1, sem nenhuma seção válida, um corpo acima de 64 KiB ou um corpo que não é JSON: o cartão inteiro fica indisponível. - Links só com
https:. Um campolinkcom outro endereço aparece como texto (o texto dele ou o endereço), e uma açãolinkcom um endereço assim é descartada.javascript:,data:e o resto nunca viram link. - Chaves de objeto desconhecidas são ignoradas, então você pode adicioná-las com antecedência.
Exemplo: um produto SaaS
{
"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."
}
]
}
Exemplo: uma assinatura e pagamentos
Um proprietário que recebe pagamentos pelo EasyPay pode marcar a assinatura e os pagamentos com ref. Presets prontos para cartões de cobrança virão depois; por enquanto este é um cartão comum.
{
"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" }
]
}
O jeito mais simples de montar um endpoint assim é a partir do seu próprio banco de dados, alimentado pelo webhook do EasyPay: o id da assinatura, o status dela, as datas de cancelamento e de fim, e os pagamentos com o payment_intent e a fatura. Não coloque uma chave de parceiro do EasyPay com todas as permissões no endpoint do cartão.
7. Ações
type | O que o painel faz |
|---|---|
link | abre um endereço https: em uma nova aba (rel="noopener noreferrer") |
copy | copia value para a área de transferência |
prompt | mostra o texto e o copia para o seu agente de IA de suporte |
Um prompt é um dado, não um comando: o painel só copia o texto e nunca o executa nem o envia para lugar nenhum. Não coloque no prompt o texto das mensagens de um visitante. Isso é uma injeção de prompt em um agente que pode ter permissões sobre dinheiro. Escreva no prompt que qualquer ação com dinheiro precisa ser confirmada por uma pessoa.
8. O que colocar no cartão
Só o proprietário do projeto vê o cartão, mas tudo o que você retorna vai para o navegador dele. Fique no que o suporte precisa: o plano, o status da assinatura, valores e datas de pagamento, contadores do produto, links para o seu admin. Não retorne dados de cartão de pagamento, senhas, tokens, endereços postais nem dados de outras pessoas. O proprietário já tem o e-mail e o nome pela mensagem.
9. Segurança do nosso lado
- Endereço. Só
https, só a porta 443. O host não pode serlocalhost, um nome.localou.internal, nem um IP literal de uma rede privada. Verificamos isso quando você salva as configurações. - Conexão. Cada endereço ao qual um socket realmente se conecta é verificado na conexão, o que impede DNS rebinding. São recusados loopback, redes privadas (RFC 1918 e ULA), link-local incluindo
169.254.169.254, endereços não especificados e multicast, CGNAT100.64.0.0/10,0.0.0.0/8,192.0.0.0/24,198.18.0.0/15,240.0.0.0/4e NAT6464:ff9b::/96com um endereço privado dentro. Proxies do ambiente não são usados, redirecionamentos não são seguidos. - Carga. No máximo 8 requisições de cartão ao mesmo tempo por servidor (acima disso o cartão fica indisponível na hora), no máximo 60 requisições por minuto por projeto, e o botão "Testar" 10 vezes por hora por projeto.
- Logs. Registramos o id do projeto, o host do endpoint, o status HTTP, a duração e a classe do erro. Nunca o corpo da requisição ou da resposta, endereços de e-mail, ids de usuário, o caminho com a query nem assinaturas.
- Renderização. Só texto puro, sem HTML e sem markdown; links só com
https:. - Desligamento. Podemos desligar as requisições de cartão para o serviço inteiro, por exemplo durante um incidente. Aí nenhuma requisição de cartão sai dos nossos servidores, o cartão e o botão "Testar" ficam indisponíveis no painel, e as suas configurações são mantidas.
10. Planos
O cartão do cliente faz parte do CRM de feedback no Indie, Studio e Fleet. Vale o plano da sua conta, e um trial não o libera. Depois de um downgrade, as suas configurações são mantidas e o cartão não é pedido até o plano voltar.
11. O contrato legível por máquina
A descrição formal do endpoint que você hospeda, da requisição, do esquema do cartão e das suas enumerações é o arquivo OpenAPI 3.1; o endpoint fica em webhooks, porque somos nós que chamamos você. Ele traz a mesma versão do contrato (1.0.0), a primeira versão deste contrato.