Protocolo SPP v1¶
Para: Dev · Adm — Conferido em 2026-10-31.
O Settlement Provider Protocol (SPP) v1 é o contrato entre o executor do Dracma e um provedor de liquidação. Esta página explica o fio: como assinar, quais cabeçalhos, cada operação com requisição e resposta completas, idempotência, erros e callbacks. Se algo aqui divergir do contrato SPP v1 publicado, o contrato vale.
O que é¶
HTTP/1.1 com JSON (UTF-8, até 1 MiB) em loopback. O executor é o único chamador e o provedor é o servidor. Toda rota fica sob /spp/v1 na porta que o ops atribui ao provedor (127.0.0.1:<porta>, nunca 0.0.0.0). Não há TLS em loopback; o isolamento é o do host (um usuário Linux por serviço).
Palavras DEVE, NÃO DEVE e PODE têm o sentido da RFC 2119. Dinheiro é sempre string inteira na menor unidade ("1050" é R$ 10,50 em BRL). Tempo é RFC 3339 em UTC. Campos do fio são snake_case em inglês.
Quando usar¶
Você lê esta página quando vai escrever um provedor, depurar uma chamada do executor ou operar um provedor de terceiro. Quem só usa a API pública não precisa dela.
Transporte e autenticação (HMAC v2)¶
Toda rota exige o HMAC interno v2, o mesmo dos demais serviços internos. O cliente envia:
| Cabeçalho | Valor |
|---|---|
Dracma-Caller |
Sempre executor. Qualquer outro chamador recebe 403 caller_not_allowed. |
Dracma-Internal-Timestamp |
Segundos Unix, só dígitos ASCII, sem sinal e sem zero à esquerda. |
Dracma-Internal-Nonce |
Texto único por chamada (32 hex no exemplo). |
Dracma-Internal-Signature |
v2= seguido do HMAC-SHA256 em hexadecimal minúsculo. |
O canônico é a concatenação, separada por quebras de linha (\n), de seis partes:
v2
<timestamp>
<nonce>
<MÉTODO>
<path?query>
<sha256 hex do corpo>
A assinatura é HMAC-SHA256(segredo, canônico). Pontos que costumam errar:
- O path assinado é o path completo, com query se houver:
/spp/v1/charges, e não/charges. - O hash do corpo é do corpo exato que vai na rede, byte a byte. Corpo vazio (GET) tem o hash de
"", que ée3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. - O segredo do par é
INTERNAL_HMAC_<PROVEDOR>_EXECUTOR, com o nome do provedor em maiúsculas e-virando_; o ops o semeia. Cada provedor tem o seu, então comprometer um não assina como outro. - Como a credencial do tenant viaja no corpo, o hash do corpo no canônico a protege contra adulteração.
Como calcular, com o vetor público¶
O segredo público de teste do contrato de internos é:
teste-publico-interno-v2-nao-usar-em-producao-0123456789
Conferência do algoritmo com o primeiro vetor oficial (corpo {"withdrawal_id":"01920000-0000-7000-8000-000000000004","amount":"5000"}, timestamp 1790000000):
canônico:
v2
1790000000
0123456789abcdef0123456789abcdef
POST
/internal/v1/decide/withdrawal
4f31a796341318d4efd9081f79f647bcf549d151eef7a8f1e3d69112e0b6950b
Dracma-Internal-Signature: v2=a140ebbafd56cb1c0658bf174e3627fcc2ae2cd3891f6a9c191ec23bb484ac20
Se a sua implementação reproduz essa assinatura, o HMAC está certo; troque só o path e o corpo para o SPP.
Exemplo completo de requisição assinada¶
Mesmo segredo de teste, agora numa criação de cobrança (corpo reduzido para caber, porém real o bastante para o cálculo):
SEGREDO='teste-publico-interno-v2-nao-usar-em-producao-0123456789'
TS=1790000300
NONCE=aabbccddeeff00112233445566778899
BODY='{"method":"pix","amount":"1050","currency":"BRL","deposit_id":"01920000-0000-7000-8000-000000000010"}'
HASH=$(printf '%s' "$BODY" | sha256sum | cut -d' ' -f1)
CANON=$(printf 'v2\n%s\n%s\nPOST\n/spp/v1/charges\n%s' "$TS" "$NONCE" "$HASH")
SIG=$(printf '%s' "$CANON" | openssl dgst -sha256 -hmac "$SEGREDO" | awk '{print $2}')
curl -sS http://127.0.0.1:9101/spp/v1/charges \
-H 'Content-Type: application/json' \
-H 'Dracma-Caller: executor' \
-H "Dracma-Internal-Timestamp: $TS" \
-H "Dracma-Internal-Nonce: $NONCE" \
-H "Dracma-Internal-Signature: v2=$SIG" \
-H 'Spp-Version: 1.0' \
-H 'Spp-Idempotency-Key: 01920000-0000-7000-8000-000000000010' \
--data-binary "$BODY"
Valores intermediários, para você comparar com a sua implementação:
sha256(corpo) = f0470e24bcf9c56fcf5af20f3df413bd3dabfa370bd1db88dca88dfdf2e62c65
canônico:
v2
1790000300
aabbccddeeff00112233445566778899
POST
/spp/v1/charges
f0470e24bcf9c56fcf5af20f3df413bd3dabfa370bd1db88dca88dfdf2e62c65
Dracma-Internal-Signature: v2=e45c183cadef60276c0b3e9b5090b0e5d2240748cf1a9849a089d058c07f49d6
Use --data-binary (ou equivalente): qualquer reformatação do JSON muda o hash e a assinatura deixa de bater.
Segredo público
Esse segredo é público e só serve em teste. O segredo real do par é semeado pelo ops e nunca aparece em documentação, log ou repositório.
Ordem de recusas do servidor¶
O provedor verifica nesta ordem e responde 401 (exceto o último, 403), sempre com application/problem+json:
| Ordem | code |
Quando |
|---|---|---|
| 1 | missing_signature |
Falta o cabeçalho de assinatura. |
| 2 | unknown_caller |
Dracma-Caller ausente ou desconhecido. |
| 3 | unsupported_signature_version |
A assinatura não começa com v2=. |
| 4 | timestamp_out_of_window |
Fora de ±300 s, ou formato inválido (sinal, espaço, zero à esquerda). |
| 5 | invalid_nonce |
Nonce em formato inválido. |
| 6 | invalid_signature |
O HMAC não confere (comparação em tempo constante). |
| 7 | replayed_nonce |
Nonce já visto na janela de 600 s. |
| 8 | caller_not_allowed |
Autenticado, mas o chamador não é executor (403). |
O provedor guarda os nonces por 600 s; memória basta. Reiniciar apaga o cache, e a janela de ±300 s do timestamp limita o dano.
Cabeçalhos do SPP¶
| Cabeçalho | Sentido | Uso |
|---|---|---|
Spp-Version |
pedido e resposta | Versão negociada MAJOR.MINOR. Obrigatório no pedido a partir da segunda chamada após o descriptor. O provedor responde com a versão que aplicou. |
Spp-Idempotency-Key |
pedido | Obrigatório em POST /charges e POST /payouts. |
Spp-Timeout-Ms |
pedido | Orçamento total da chamada. O provedor DEVE limitar a chamada ao parceiro a esse valor menos 500 ms. |
traceparent, tracestate |
pedido | Contexto W3C Trace Context do executor. O provedor continua o trace e NÃO os repassa ao parceiro. |
Spp-Replayed: true |
resposta | A resposta repete uma execução idempotente anterior. |
Retry-After |
resposta | Segundos de espera em 429 e 503 do provedor. |
O executor mantém um cliente HTTP por provedor: keep-alive, conexão em até 1 s, sem proxy e sem redirecionamento.
Versionamento, negociação e depreciação¶
spp_versionéMAJOR.MINOR; esta é a1.0. O MAJOR está no path (/spp/v1). Um MAJOR novo é outro path (/spp/v2), e os dois podem coexistir num provedor.- MINOR só adiciona: campo opcional novo, valor novo de capacidade ou de código de erro, operação opcional nova. Remover, renomear, tornar obrigatório ou mudar semântica é MAJOR.
- Leitor tolerante nos dois lados: campo desconhecido é ignorado. Status de cobrança ou saque desconhecido vira
unrecognizede nunca move dinheiro.codede erro desconhecido é tratado pela família do status HTTP. - Negociação: o descriptor traz
spp_version(a que o provedor fala melhor) espp_versions_supported. O executor escolhe a maior versão comum que ele também fala e a envia emSpp-Version. Sem versão comum, o registro recusa comincompatible_spp_versione o provedor não chega aenabled. - Depreciação: o executor suporta o MAJOR corrente e o anterior por pelo menos 12 meses após o anúncio. Itens depreciados aparecem em
deprecationsdo descriptor comfeature,sunsetereplacement; o provedor PODE emitirDeprecationeSunset(RFC 8594). A certificação falha para quem usa recurso já vencido.
Operações¶
Todas as rotas são POST com corpo JSON, exceto GET /spp/v1/descriptor e GET /spp/v1/health. Operações de negócio levam context, config e credential.
Mapa PspConnector para rota SPP¶
PspConnector |
Rota SPP | Capacidade no descriptor |
|---|---|---|
psp() |
GET /spp/v1/descriptor (campo name) |
nenhuma |
| descriptor e capacidades | GET /spp/v1/descriptor |
nenhuma |
| health | GET /spp/v1/health |
nenhuma |
create_charge |
POST /spp/v1/charges |
directions contém cash_in |
get_charge |
POST /spp/v1/charges/get |
charge_lookup |
resolve_charge_ref |
POST /spp/v1/charges/resolve-ref |
charge_ref_resolution |
create_payout |
POST /spp/v1/payouts |
directions contém cash_out |
get_payout e get_payout_with_fee |
POST /spp/v1/payouts/get |
directions contém cash_out |
verify_callback |
POST /spp/v1/callbacks/verify |
callbacks |
dict_lookup |
POST /spp/v1/keys/lookup |
key_lookup |
account_balance |
POST /spp/v1/account/balance |
balance |
check_credential |
POST /spp/v1/credentials/check |
credential_check |
| cancelar ou expirar (cripto) | POST /spp/v1/charges/cancel |
charge_cancel |
not_found_is_definitive |
descriptor, quirks.not_found_is_definitive |
nenhuma |
Operação sem capacidade responde 501 unsupported e o descriptor a declara false; o executor nunca infere suporte pelo nome do provedor. Capacidade true obriga a operação a funcionar e a ser coberta pela certificação.
Estruturas comuns¶
{
"context": {
"connection_id": "01920000-0000-7000-8000-0000000000c1",
"operation_id": "01920000-0000-7000-8000-0000000000a1",
"mode": "sandbox",
"runtime_env": "hml"
},
"config": { "base_url": "https://sandbox.parceiro.example/api" },
"credential": { "client_id": "exemplo-nao-usar", "client_secret": "exemplo-nao-usar" }
}
context.connection_idescopa cache e circuito e não é segredo.operation_idserve a correlação e log, nunca a autorização.modeésandboxoulive, decidido pelo executor.runtime_envédev,hmlouprd.configé não secreta, por conexão, validada contra oconfig_schemado descriptor. Se trouxerbase_url, o host DEVE estar emegress_hosts.credentialvalida contra ocredential_schemae nunca vai a URL, cabeçalho ou log.- Os blocos abaixo abreviam essas três estruturas como
context,configecredential; no fio elas vão completas.
GET /spp/v1/descriptor¶
Sem corpo, autenticado por HMAC, sem credencial. Resposta resumida (o schema completo está em Descriptor):
{
"name": "exemplo",
"display_name": "Parceiro Exemplo",
"version": "1.2.0",
"spp_version": "1.0",
"spp_versions_supported": ["1.0"],
"synthetic": false,
"capabilities": {
"directions": ["cash_in", "cash_out"],
"methods": [{ "method": "pix", "directions": ["cash_in", "cash_out"] }],
"balance": false,
"charge_lookup": true,
"charge_ref_resolution": false,
"charge_cancel": false,
"credential_check": true,
"key_lookup": { "dict": false, "key_types": [] },
"fee_reporting": "on_query",
"refunds": { "payout_returns": false, "charge_refund": false },
"callbacks": true,
"event_delivery": "callback"
},
"egress_hosts": ["api.parceiro.example:443", "sandbox.parceiro.example:443"]
}
POST /spp/v1/charges (create_charge)¶
Cria a cobrança no parceiro. O cabeçalho Spp-Idempotency-Key DEVE coincidir com deposit_id (em cripto, com charge_id), senão 400 invalid_request. O campo method discrimina o corpo.
Requisição (method igual a pix):
{
"method": "pix",
"context": { "connection_id": "01920000-0000-7000-8000-0000000000c1", "operation_id": "01920000-0000-7000-8000-0000000000a1", "mode": "sandbox", "runtime_env": "hml" },
"config": {},
"credential": { "client_id": "exemplo-nao-usar", "client_secret": "exemplo-nao-usar" },
"deposit_id": "01920000-0000-7000-8000-000000000010",
"amount": "1050",
"currency": "BRL",
"payer": { "name": "Maria Exemplo", "document": "12345678909", "email": null, "phone": null },
"callback_url": "https://callbacks.dracma.invalid/callbacks/exemplo/01920000-0000-7000-8000-0000000000c1",
"expires_at": "2026-11-01T12:00:00Z"
}
Resposta 200 (ou 201 na primeira execução; o executor trata igual):
{
"psp_ref": "ch_9f3a21",
"expires_at": "2026-11-01T12:00:00Z",
"pix": { "copy_paste": "00020126580014br.gov.bcb.pix0136exemplo", "qr_code_png_base64": null }
}
Para method igual a crypto, o pedido traz charge_id, asset, network, pay_amount e expires_at, e a resposta traz o bloco crypto:
{
"psp_ref": "01920000-0000-7000-8000-000000000020",
"expires_at": "2026-11-01T12:00:00Z",
"crypto": {
"address": "TExemploEnderecoDeTeste0000000000",
"payment_uri": "tron:TExemploEnderecoDeTeste0000000000?amount=10.5",
"derivation_index": 7,
"network": "tron",
"asset": "USDT",
"confirmations_required": 20,
"watch_until": "2026-11-02T12:00:00Z"
}
}
Cobrança não move dinheiro: falha de infraestrutura habilita o failover de depósito. Se o parceiro não devolve identificador, o provedor devolve o próprio deposit_id como psp_ref.
POST /spp/v1/charges/get (get_charge)¶
O fetch-back: o executor pergunta o estado real antes de qualquer efeito. Pede por deposit_id, ou por psp_ref quando o parceiro só consulta por ele.
{ "context": {}, "config": {}, "credential": {}, "deposit_id": "01920000-0000-7000-8000-000000000010" }
Resposta (state é pending, paid, expired, observed ou unrecognized):
{
"state": "paid",
"paid": { "amount": "1050", "payer_document": "12345678909", "payer_name": "Maria Exemplo" },
"facts": null
}
paid(PIX) traz o valor e o pagador reais; o executor relata e a API decide.payer_document(só dígitos) epayer_namesão opcionais.observedé só cripto, comfactsigual a{received, confirmations, confirmations_required, txids[], reorged}. O provedor cripto nunca afirmapaid.unrecognizedé um status que o provedor não conhece; nunca move dinheiro.- Sem a capacidade
charge_lookup, a rota responde501 unsupported.
POST /spp/v1/charges/resolve-ref (resolve_charge_ref)¶
Usada quando o callback só traz a referência do parceiro (evento charge_by_ref).
{ "context": {}, "config": {}, "credential": {}, "psp_ref": "ch_9f3a21" }
{ "deposit_id": "01920000-0000-7000-8000-000000000010" }
"deposit_id": null com 200 significa que o pagamento não é de um depósito do Dracma; não é erro.
POST /spp/v1/payouts (create_payout)¶
Pode mover dinheiro. idempotency_key repete o cabeçalho Spp-Idempotency-Key e é o psp_idempotency_key do saque.
{
"context": {},
"config": {},
"credential": {},
"withdrawal_id": "01920000-0000-7000-8000-000000000030",
"idempotency_key": "wd-01920000-0000-7000-8000-000000000030",
"amount": "5000",
"currency": "BRL",
"destination": { "pix_key_type": "cpf", "pix_key": "12345678909" },
"owner": { "name": "Maria Exemplo", "document": "12345678909", "ip": "203.0.113.7" },
"callback_url": "https://callbacks.dracma.invalid/callbacks/exemplo/01920000-0000-7000-8000-0000000000c1"
}
{ "psp_ref": "po_77c0de", "fee": { "state": "ok", "amount": "150" } }
pix_key_typeécpf,cnpj,email,phoneouevp.owner.ipé o IPv4 do jogador e é obrigatório.feeé{"state":"ok","amount":"150"},{"state":"absent"}ou{"state":"unrecognized"}. Ler a tarifa nunca é erro de envio: ilegível viraunrecognized, e o estado do saque não depende dela.okexige0 <= fee <= amount.- O adaptador normaliza
owner.nameconforme o parceiro. - Recusa de negócio (
upstream_rejected) na criação do saque continua sendo tratada pelo executor como ambígua; é regra do núcleo, não do provedor.
POST /spp/v1/payouts/get (get_payout)¶
Fonte de verdade para resolver ambiguidade. O provedor DEVE responder para qualquer chave já aceita em payouts.
{ "context": {}, "config": {}, "credential": {}, "idempotency_key": "wd-01920000-0000-7000-8000-000000000030" }
{
"status": { "state": "settled", "psp_ref": "po_77c0de", "e2e": "E12345678202611011200abcdef01234", "amount": "5000" },
"fee": { "state": "ok", "amount": "150" }
}
status.state |
Campos | Significado |
|---|---|---|
not_found |
nenhum | O parceiro não conhece a chave. Definitivo só se quirks.not_found_is_definitive for true (padrão false: silêncio não é definitivo). |
processing |
psp_ref |
Em processamento. |
settled |
psp_ref, e2e, amount |
Pago; amount é o realmente enviado. |
failed |
psp_ref, reason |
Falhou de forma definitiva; reason curto no formato [a-z0-9_]{1,40}, nunca texto livre do parceiro. |
returned |
psp_ref, amount, reason?, returned_at? |
Pago e devolvido pelo banco do recebedor. Só vale após confirmar por consulta. Exige capabilities.refunds.payout_returns. |
unrecognized |
nenhum | Status desconhecido: tolerante, nunca move dinheiro. |
POST /spp/v1/callbacks/verify (verify_callback)¶
Detalhado em Callbacks. Recebe o corpo cru em base64 e devolve eventos.
POST /spp/v1/keys/lookup (dict_lookup)¶
Consulta de chave PIX no DICT. Capacidade key_lookup.
{ "context": {}, "config": {}, "credential": {}, "key_type": "cpf", "key": "12345678909" }
{ "owner": { "document": "12345678909", "document_masked": "***.456.789-**", "name": "Maria Exemplo" } }
O chamador compara o documento completo e o descarta; o provedor NÃO o registra. Chave inexistente é 404 key_not_found (negócio, effect: none).
POST /spp/v1/account/balance¶
{ "context": {}, "config": {}, "credential": {} }
{ "available": "123456", "blocked": null, "currency": "BRL" }
blocked é null quando o parceiro não informa. Formato irreconhecível responde 502 unrecognized_response: saldo desconhecido nunca é saldo zero.
POST /spp/v1/credentials/check¶
Faz uma chamada de leitura inócua ao parceiro; nunca cria efeito.
{ "context": {}, "config": {}, "credential": {} }
{ "valid": false, "reason": "invalid_credentials", "scopes": [] }
reason é null, invalid_credentials, insufficient_scope ou wrong_mode. Credencial recusada pelo parceiro é resposta 200 com valid: false; erro é para falha de infraestrutura.
POST /spp/v1/charges/cancel (opcional)¶
Capacidade charge_cancel, usada pelo provedor cripto para expirar cobranças. Idempotente; o endereço segue vigiado até watch_until.
{ "context": {}, "config": {}, "credential": {}, "deposit_id": "01920000-0000-7000-8000-000000000020" }
{ "state": "cancelled" }
state também pode ser already_terminal.
GET /spp/v1/health¶
{
"status": "ok",
"version": "1.2.0",
"spp_version": "1.0",
"checks": [{ "name": "upstream_auth", "status": "ok", "detail": null }]
}
Idempotência¶
Spp-Idempotency-Keyé obrigatório emPOST /charges(igual aodeposit_id; em cripto, aocharge_id) e emPOST /payouts(igual aopsp_idempotency_key). Cabeçalho e campo do corpo DEVEM coincidir.- O provedor DEVE ser efetivamente idempotente: mesma chave e mesmo corpo (desconsiderando
credentialecontext.operation_id) devolvem o mesmo resultado, comSpp-Replayed: true, sem novo efeito no parceiro. - Mesma chave com corpo diferente é
409 idempotency_conflict. - Duas formas válidas: repassar a chave como identificador de idempotência do parceiro (
upstream_dedupe: true, comoidentifierna MaximaxPay eX-Idempotency-Keyno Mercado Pago), ou manter um repositório local chave para resultado, com retenção de pelo menosretention_hours(no mínimo 24 h), sem segredo e sem PII além do necessário. - Chamada concorrente com a mesma chave: uma executa e as outras esperam ou recebem
409 idempotency_in_progresscomeffect: unknown. - Leituras (
get_*,balance,check,lookup) já são naturalmente idempotentes. - O executor nunca reenvia saque às cegas: ambíguo vira
unknowne é resolvido porpayouts/get.
Erros: taxonomia e mapeamento¶
Falha é HTTP não-2xx com application/problem+json (RFC 9457) mais extensões:
{
"type": "https://dracma.invalid/spp/errors/upstream_timeout",
"title": "Parceiro não respondeu",
"status": 504,
"code": "upstream_timeout",
"effect": "unknown",
"retryable": false,
"upstream_status": null,
"field": null,
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
codeé um enum aberto (veja a tolerância em versionamento).effecténonequando o provedor garante que nenhum efeito ocorreu no parceiro, eunknownquando pode ter ocorrido. Na dúvida,unknown.retryableindica que o executor PODE repetir a mesma chamada com a mesma chave. É informativo: o executor segue as próprias regras.upstream_statusé o HTTP do parceiro, quando houve. Odetailnunca carrega corpo do parceiro, credencial ou PII.
Tabela normativa¶
| code | HTTP | effect | Origem | PspError no executor | Conta para o breaker | Failover de depósito |
|---|---|---|---|---|---|---|
upstream_timeout |
504 | unknown | Parceiro não respondeu no prazo, ou conexão caiu depois do envio | Timeout |
sim | sim (marca ambiguous) |
upstream_unavailable |
502 ou 503 | unknown | Parceiro respondeu 5xx, ou resposta ilegível após o envio | Unavailable(upstream_status) |
sim | sim (marca ambiguous) |
upstream_rejected |
422 | none | Parceiro respondeu 4xx: recusa de negócio, credencial recusada, valor inválido | Rejected(upstream_status) |
não (business_rejected) |
não |
unrecognized_response |
502 | unknown | Parceiro respondeu 2xx com corpo irreconhecível | Unrecognized |
não (resultado próprio) | não |
unsupported |
501 | none | Operação ou capacidade inexistente neste provedor | Unsupported |
não | não |
misconfigured |
422 | none | Credencial ou config fora do schema, URL base inválida (field indica o campo) |
Misconfigured |
não | não |
environment_forbidden |
403 | none | A guarda do provedor recusou o modo ou ambiente | Misconfigured (guarda) |
não | não |
callback_unauthenticated |
422 | none | Assinatura ausente ou inválida, corpo malformado (só callbacks/verify) |
CallbackUnauthenticated |
não | não se aplica |
key_not_found |
404 | none | Chave DICT inexistente | Rejected(404) |
não | não se aplica |
provider_busy |
429 | none | Limite do próprio provedor (fila cheia), com Retry-After |
CircuitOpen em create_charge, Unavailable(429) nas demais |
sim | sim (nada saiu) |
idempotency_conflict |
409 | none | Mesma chave com corpo diferente | Misconfigured (bug do chamador) |
não | não |
invalid_request |
400 | none | Corpo malformado ou fora do contrato SPP | Misconfigured |
não | não |
incompatible_spp_version |
400 | none | Spp-Version sem versão comum |
Misconfigured |
não | não |
missing_signature, invalid_signature e demais do HMAC |
401 | none | Autenticação interna | Misconfigured mais alerta |
não | não |
caller_not_allowed |
403 | none | Chamador autenticado diferente de executor |
Misconfigured mais alerta |
não | não |
internal_error |
500 | unknown | Bug do provedor | Unavailable(500) |
sim | sim (ambíguo) |
Dois casos que não são erro:
NotFoundé200comstatus.stateigual anot_foundempayouts/get. Cobrança sem registro devolvependingouunrecognized, conforme o parceiro. Se é definitivo depende dequirks.not_found_is_definitive.CircuitOpené decisão do núcleo, não do provedor.
Exemplos de problem+json¶
Recusa de negócio do parceiro (valor inválido):
{
"type": "https://dracma.invalid/spp/errors/upstream_rejected",
"title": "Parceiro recusou a operação",
"status": 422,
"code": "upstream_rejected",
"effect": "none",
"retryable": false,
"upstream_status": 400,
"field": null,
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
Credencial fora do schema (campo desconhecido):
{
"type": "https://dracma.invalid/spp/errors/misconfigured",
"title": "Credencial fora do schema",
"status": 422,
"code": "misconfigured",
"effect": "none",
"retryable": false,
"upstream_status": null,
"field": "credential.token",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
Guarda de ambiente (modo live fora de produção):
{
"type": "https://dracma.invalid/spp/errors/environment_forbidden",
"title": "Modo live proibido neste ambiente",
"status": 403,
"code": "environment_forbidden",
"effect": "none",
"retryable": false,
"upstream_status": null,
"field": "context.mode",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
Limite do provedor:
{
"type": "https://dracma.invalid/spp/errors/provider_busy",
"title": "Provedor ocupado",
"status": 429,
"code": "provider_busy",
"effect": "none",
"retryable": true,
"upstream_status": null,
"field": null,
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
(com o cabeçalho Retry-After: 2 na resposta).
O que o executor decide sozinho¶
Algumas falhas o provedor não consegue declarar; o executor as classifica:
- Sem resposta no prazo de
Spp-Timeout-Ms, conexão caída depois de enviar o corpo, 5xx semproblem+jsonou corpo ilegível: ambíguo, comoTimeoutouUnavailable(status). connection refused, ou provedordisabled,unpluggedouunhealthyno registro (nenhum byte enviado): definitivamente não saiu.create_chargeviraCircuitOpen(failover seguro); as demais operações viramUnavailable(503), o conservador, porque saque nunca assume "não saiu".- Breaker aberto:
CircuitOpensem chamar o provedor. A janela é de 30 s, com no mínimo 20 amostras e 50% de falhas, 30 s aberto e uma sonda única. O breaker é por provedor, operação e conexão, e só contaTimeout,UnavailableeCircuitOpende infraestrutura. - Estouro do prazo do chamador: a chamada é abandonada e conta
timeout.
Invariantes que o provedor DEVE preservar¶
- Nunca converter falha ambígua em definitiva (
Timeoutou 5xx do parceiro éeffect: unknown), nem recusa definitiva em ambígua. create_payoutcom falha de rede depois do envio éupstream_timeout; o executor marcaunknowne consulta, jamais reenvia.- Status ou campo irreconhecível do parceiro vira
unrecognized; nuncasettled, nunca saldo zero. - Mensagens sem corpo do parceiro, sem credencial, sem documento e sem chave PIX.
- O failover de depósito só troca de provedor por
Timeout,UnavailableouCircuitOpen; saque nunca tem failover automático.
Credencial por chamada¶
Decisão: o executor guarda a credencial cifrada e a envia decifrada, por chamada, no corpo, em loopback. O provedor não guarda segredo de longa duração.
| Opção | Exposição | Veredito |
|---|---|---|
| A. Executor decifra e envia por chamada | O segredo existe em claro só na memória do executor e do provedor, durante a chamada. Cada provedor só vê credenciais do seu tipo. Comprometer o provedor expõe só as chamadas em voo, nunca o cofre. Desplugar não deixa segredo. A rotação vale na próxima chamada. | Escolhida |
| B. Provedor busca no cofre | O provedor ganharia acesso ao KMS e aos envelopes e comprometeria todas as credenciais de todos os tenants. | Rejeitada |
| C. Provedor guarda a credencial | Segredo de longa duração em outro serviço, rotação dupla e deriva entre registro e provedor. | Rejeitada |
| D. Token de curta duração emitido pelo executor | Exigiria um emissor por parceiro, com complexidade sem ganho sobre A em loopback. | Rejeitada |
Regras:
credentialvai no corpo (coberta pelo hash do HMAC), nunca em URL, query, cabeçalho nem log. O executor decifra na hora do uso e zera a memória depois.- O provedor NÃO DEVE persistir a credencial, escrevê-la em log, trace, métrica ou erro, devolvê-la em resposta, nem enviá-la a quem não seja o parceiro declarado. DEVE zerar buffers e redigi-la em
DebugetoString. - Cache permitido: token derivado de curta duração (por exemplo OAuth), só em memória, com chave
(connection_id, sha256(credential))e TTL de no máximo o menor entre a expiração do token e 10 minutos. Credencial mudou, hash mudou, cache não é usado. - O
credential_schemadeclara cada campo e marca segredos comx-secret: true; o painel se desenha dele. Campo desconhecido no pedido é422 misconfiguredcomfield. - O único segredo permanente do provedor é o par HMAC com o executor, rotacionável sem tocar credencial de tenant.
verify_callbacktambém recebecredential, porque o segredo do webhook faz parte dela.
Mais sobre o tema em Segurança.
Callbacks¶
Roteamento¶
- O parceiro chama
https://<host-de-callback>/callbacks/{provider}/{connection_id}, URL montada pelo executor e passada emcallback_urlna criação. - O ingress encaminha qualquer provedor registrado e habilitado para o executor. Provedor novo não exige editar rota.
- O executor acha a conexão (se o
providerdo path não for o da conexão, trata como desconhecida), confere o IP de origem contracallbacks.source_ipsdo modo (quando não vazio), aplica a guarda de ambiente e chama o provedor. - Resposta ao parceiro: autêntico e aplicado é
200. Assinatura inválida ou conexão desconhecida é401uniforme (mesmo corpo, custo parecido). Falha transitória (aplicar, provedor fora) é503, para o parceiro reenviar, com log (trace_id) e alerta.
O callback nunca é a única fonte: o reconciliador e o fetch-back cobrem o caso de o parceiro desistir.
Verificação¶
Requisição:
{
"context": {},
"config": {},
"credential": { "access_token": "exemplo-nao-usar", "webhook_secret": "whsec_test_exemplo", "test_credential": true },
"request": {
"method": "POST",
"path": "/callbacks/mercadopago/01920000-0000-7000-8000-0000000000c1",
"headers": { "x-signature": "ts=1790000000,v1=exemplo", "x-request-id": "req-123" },
"query": "data.id=123&type=payment",
"body_base64": "eyJhY3Rpb24iOiJwYXltZW50LnVwZGF0ZWQifQ==",
"received_at": "2026-10-31T12:00:00Z"
}
}
Resposta 200, com zero ou mais eventos (lista vazia é autêntico sem efeito):
{
"events": [
{ "type": "charge_by_ref", "psp_ref": "123" }
]
}
Event.type |
Campos | Equivale a |
|---|---|---|
payout |
idempotency_key, claimed (estado do saque) |
CallbackEvent::Payout |
charge |
deposit_id, claimed (estado da cobrança) |
CallbackEvent::Charge |
charge_by_ref |
psp_ref |
CallbackEvent::ChargeByPspRef: o executor chama resolve-ref e depois charges/get |
ignored |
topic? |
CallbackEvent::Ignored |
Regras:
- Os nomes de cabeçalho vão em minúsculas, e só seguem os do
callbacks.signaturedo descriptor maiscontent-type. - O provedor verifica sobre os bytes crus de
body_base64e NÃO reserializa o JSON. claimedé o que o callback afirma. O executor confere por fetch-back antes de qualquer efeito. Devolução (returned) só move estado depois de confirmada por consulta.- Assinatura inválida é
422 callback_unauthenticated. A comparação é em tempo constante, a janela de tempo é validada quando o parceiro assina timestamp, e o motivo fino nunca volta ao executor, para não virar oráculo. - Os esquemas de hoje: a MaximaxPay assina o corpo cru com HMAC-SHA256 em
x-signature: sha256=<hex>; o Mercado Pago assina um manifesto (id,request-idets) emx-signature: ts=<ts>,v1=<hex>.
Isca¶
Para que o callback de uma conexão inexistente não revele que ela não existe, o executor gera uma credencial aleatória no formato do credential_schema e chama callbacks/verify do provedor do path (se registrado). O custo fica parecido com o de uma conexão real e a resposta ao parceiro é sempre 401. O provedor não pode distinguir e não deve tentar. Se a isca "autenticar", o executor registra erro grave e recusa mesmo assim.
Saúde e observabilidade¶
O executor consulta GET /spp/v1/health a cada 10 s. Três falhas seguidas marcam o provedor unhealthy no registro (sem trabalho novo, o failover escolhe outro). Dois sucessos o reabilitam. Saúde não substitui o breaker, que reage ao tráfego real.
statuséok,degraded(opera com restrição) oudown(sabe que não consegue operar). Oschecksnunca fazem chamada que crie efeito nem usam credencial de tenant.- Métricas do executor (por chamada, com
psp=<nome>):dracma_psp_requests_total,dracma_psp_request_duration_seconds,dracma_psp_circuit_state; edracma_spp_health,dracma_spp_registry_state,dracma_spp_inflight. O rótulooutcomeassumesuccess,business_rejected,circuit_open,timeout,unavailable,unsupported,misconfigured,unauthenticatedouunrecognized. - Métricas do provedor (OpenTelemetry e Prometheus,
service.nameigual adracma_psp_<nome>):spp_upstream_requests_total,spp_upstream_duration_seconds,spp_idempotent_replays_total,spp_callbacks_verified_total. Sem tenant nemconnection_idnos rótulos, por cardinalidade. - Traces: o executor cria o span
psp.<op>e enviatraceparent; o provedor continua o trace e criaupstream.<op>para o parceiro. Otraceparentnão sai para o parceiro, que é terceiro. - Logs em JSON com
trace_id,span_id,connection_id,provider,opeoutcome; nunca credencial, corpo do parceiro, documento, chave PIX ou nome. - Dashboards e alertas (provedor
down, breaker aberto,unrecognized_response, drenagem parada) são versionados como código no Helm chart.
Limites e retentativas¶
O executor aplica psp_timeout a cada chamada e o envia em Spp-Timeout-Ms; payout_create_ms do descriptor pode elevar o teto do saque até max_ms. O rate_limits do descriptor alimenta um balde por provedor e conexão no executor. Leituras podem ser repetidas com backoff, create_charge repete com a mesma chave, e create_payout nunca é repetido às cegas.
Guarda de ambiente¶
Dois conceitos: modo (sandbox ou live) e ambiente de execução (dev, hml ou prd, fail-closed: ausente vale como não-prd). O executor decide o modo e o provedor o confirma.
| Provedor | Ambiente | Modo permitido | Regra |
|---|---|---|---|
synthetic igual a true |
dev e hml | sandbox |
Recusado em prd e em operação production. |
synthetic igual a false |
prd | live |
sandbox em prd é recusado. Credencial de teste é recusada. |
synthetic igual a false |
dev e hml | sandbox |
Só se environments.sandbox existir, com a URL de sandbox; a credencial DEVE declarar teste (credential_mode). |
synthetic igual a false, sem sandbox |
dev e hml | live (exceção) |
Exige environment_policy.live_outside_prd igual a flag, PSP_REAL_OUTSIDE_PRD contendo o name, ambiente hml e operação production. Senão, a guarda recusa. |
PSP_REAL_OUTSIDE_PRD=<nome>[,<nome>]fica no seeder da aplicação e só o ops a escreve, com auditoria.- O executor escolhe
config.base_urldeenvironments[mode].base_url(ou de um valor deconfigcom host emegress_hosts). Fora de prd, nunca usa a URLlive, salvo na exceção da tabela. credential_modecom{field: "test_credential", test_value: true}exige a credencial de teste emsandboxe de produção emlive. O provedor PODE recusar também pelo padrão do token, comenvironment_forbidden.- Defesa em profundidade: o provedor recusa
livecomruntime_envdiferente deprd(salvo política permissiva econtext.live_exception: true) e recusasandboxemprd. - Efeito externo nunca sai de não-produção: fora de prd o provedor opera só contra sandbox ou fake, e a certificação prova a guarda.
Erros comuns¶
- Assinar o path sem o prefixo
/spp/v1, ou assinar o JSON reformatado em vez dos bytes enviados. - Devolver
404HTTP para saque desconhecido empayouts/get: o correto é200comstate: not_found. - Tratar
upstream_rejecteddo parceiro em criação de saque como definitivo no provedor: quem decide ambiguidade é o executor. - Responder
200ao callback sem verificar a assinatura sobre os bytes crus. - Colocar
traceparentou credencial em chamadas ao parceiro, ou em logs.
Próximos passos¶
- Descriptor: declare capacidades, schemas e hosts de saída.
- Escrever um provedor: implemente estas rotas passo a passo.
- Certificação: prove que o provedor cumpre este protocolo.
- Visão geral da seção.