Ext Ops Panel
Abrir o painel

Início/Docs/Cartão do cliente

Contrato do cartão do cliente

Versão do contrato 1.0.0 · parte do CRM de feedback no Indie, Studio e Fleet

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.

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

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:

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):

  1. Endereço do endpoint. https://…, só a porta 443, um endereço público (seção 9).
  2. 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.
  3. 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.
  4. Testar. Envia uma requisição com test: true para uma identidade que você digita (por padrão test e test@example.com). O painel mostra "Cartão recebido, N campos", ou o status HTTP e até 300 bytes do corpo da resposta.
  5. 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}
CampoTipoSignificado
schema_version"1.0"versão do esquema da requisição e da resposta
project_iduuido nosso id do projeto: um endpoint pode atender vários projetos
person.user_idstring de até 128 caracteres, ou nullo id de usuário que o seu servidor assinou (identify() mais a assinatura v1)
person.emailstring de até 254 caracteres, ou nullo e-mail que o seu servidor assinou
person.verifiedtruesempre true, porque os cartões são pedidos só para pessoas verificadas; mantido por compatibilidade
localeen ru de es fr pto idioma do painel de quem lê, para que os seus rótulos possam voltar nesse idioma
testbooleantrue 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

O seu endpoint verifica que:

  1. os dois cabeçalhos estão presentes;
  2. |now − timestamp| ≤ 300 segundos;
  3. 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.

EntradaValor
card_secretccs_test_vector_secret_not_for_production
segredo anteriorccs_previous_test_vector_secret
timestamp1760371200
METHOD / path_with_queryPOST / /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 segredo035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
assinatura com o segredo anterior1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd
cabeçalho durante a trocav1=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

HTTPSignificadoO que o proprietário vê
200 com um cartão em JSONo cartãoo cartão
404o 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 violadouma 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 campoValor
textuma 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
countum número inteiro, 0 ou mais

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.

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

typeO que o painel faz
linkabre um endereço https: em uma nova aba (rel="noopener noreferrer")
copycopia value para a área de transferência
promptmostra 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

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.

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