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 é a 1.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 unrecognized e nunca move dinheiro. code de erro desconhecido é tratado pela família do status HTTP.
  • Negociação: o descriptor traz spp_version (a que o provedor fala melhor) e spp_versions_supported. O executor escolhe a maior versão comum que ele também fala e a envia em Spp-Version. Sem versão comum, o registro recusa com incompatible_spp_version e o provedor não chega a enabled.
  • 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 deprecations do descriptor com feature, sunset e replacement; o provedor PODE emitir Deprecation e Sunset (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_id escopa cache e circuito e não é segredo. operation_id serve a correlação e log, nunca a autorização. mode é sandbox ou live, decidido pelo executor. runtime_env é dev, hml ou prd.
  • config é não secreta, por conexão, validada contra o config_schema do descriptor. Se trouxer base_url, o host DEVE estar em egress_hosts.
  • credential valida contra o credential_schema e nunca vai a URL, cabeçalho ou log.
  • Os blocos abaixo abreviam essas três estruturas como context, config e credential; 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) e payer_name são opcionais.
  • observed é só cripto, com facts igual a {received, confirmations, confirmations_required, txids[], reorged}. O provedor cripto nunca afirma paid.
  • unrecognized é um status que o provedor não conhece; nunca move dinheiro.
  • Sem a capacidade charge_lookup, a rota responde 501 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, phone ou evp. 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 vira unrecognized, e o estado do saque não depende dela. ok exige 0 <= fee <= amount.
  • O adaptador normaliza owner.name conforme 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 }]
}

Ver Saúde e observabilidade.

Idempotência

  • Spp-Idempotency-Key é obrigatório em POST /charges (igual ao deposit_id; em cripto, ao charge_id) e em POST /payouts (igual ao psp_idempotency_key). Cabeçalho e campo do corpo DEVEM coincidir.
  • O provedor DEVE ser efetivamente idempotente: mesma chave e mesmo corpo (desconsiderando credential e context.operation_id) devolvem o mesmo resultado, com Spp-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, como identifier na MaximaxPay e X-Idempotency-Key no Mercado Pago), ou manter um repositório local chave para resultado, com retenção de pelo menos retention_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_progress com effect: unknown.
  • Leituras (get_*, balance, check, lookup) já são naturalmente idempotentes.
  • O executor nunca reenvia saque às cegas: ambíguo vira unknown e é resolvido por payouts/get.
sequenceDiagram participant E as Executor participant P as Provedor participant X as Parceiro E->>P: POST /payouts (chave K) P->>X: cria saque (chave K) X--xP: sem resposta (queda de rede) P-->>E: 504 upstream_timeout (effect unknown) Note over E: marca unknown, nao reenvia E->>P: POST /payouts/get (chave K) P->>X: consulta por K X-->>P: processing P-->>E: 200 status processing

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 é none quando o provedor garante que nenhum efeito ocorreu no parceiro, e unknown quando pode ter ocorrido. Na dúvida, unknown.
  • retryable indica 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. O detail nunca 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 é 200 com status.state igual a not_found em payouts/get. Cobrança sem registro devolve pending ou unrecognized, conforme o parceiro. Se é definitivo depende de quirks.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 sem problem+json ou corpo ilegível: ambíguo, como Timeout ou Unavailable(status).
  • connection refused, ou provedor disabled, unplugged ou unhealthy no registro (nenhum byte enviado): definitivamente não saiu. create_charge vira CircuitOpen (failover seguro); as demais operações viram Unavailable(503), o conservador, porque saque nunca assume "não saiu".
  • Breaker aberto: CircuitOpen sem 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ó conta Timeout, Unavailable e CircuitOpen de infraestrutura.
  • Estouro do prazo do chamador: a chamada é abandonada e conta timeout.

Invariantes que o provedor DEVE preservar

  1. Nunca converter falha ambígua em definitiva (Timeout ou 5xx do parceiro é effect: unknown), nem recusa definitiva em ambígua.
  2. create_payout com falha de rede depois do envio é upstream_timeout; o executor marca unknown e consulta, jamais reenvia.
  3. Status ou campo irreconhecível do parceiro vira unrecognized; nunca settled, nunca saldo zero.
  4. Mensagens sem corpo do parceiro, sem credencial, sem documento e sem chave PIX.
  5. O failover de depósito só troca de provedor por Timeout, Unavailable ou CircuitOpen; 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:

  1. credential vai 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.
  2. 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 Debug e toString.
  3. 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.
  4. O credential_schema declara cada campo e marca segredos com x-secret: true; o painel se desenha dele. Campo desconhecido no pedido é 422 misconfigured com field.
  5. O único segredo permanente do provedor é o par HMAC com o executor, rotacionável sem tocar credencial de tenant.
  6. verify_callback também recebe credential, porque o segredo do webhook faz parte dela.

Mais sobre o tema em Segurança.

Callbacks

Roteamento

  1. O parceiro chama https://<host-de-callback>/callbacks/{provider}/{connection_id}, URL montada pelo executor e passada em callback_url na criação.
  2. O ingress encaminha qualquer provedor registrado e habilitado para o executor. Provedor novo não exige editar rota.
  3. O executor acha a conexão (se o provider do path não for o da conexão, trata como desconhecida), confere o IP de origem contra callbacks.source_ips do modo (quando não vazio), aplica a guarda de ambiente e chama o provedor.
  4. Resposta ao parceiro: autêntico e aplicado é 200. Assinatura inválida ou conexão desconhecida é 401 uniforme (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.signature do descriptor mais content-type.
  • O provedor verifica sobre os bytes crus de body_base64 e 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-id e ts) em x-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) ou down (sabe que não consegue operar). Os checks nunca 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; e dracma_spp_health, dracma_spp_registry_state, dracma_spp_inflight. O rótulo outcome assume success, business_rejected, circuit_open, timeout, unavailable, unsupported, misconfigured, unauthenticated ou unrecognized.
  • Métricas do provedor (OpenTelemetry e Prometheus, service.name igual a dracma_psp_<nome>): spp_upstream_requests_total, spp_upstream_duration_seconds, spp_idempotent_replays_total, spp_callbacks_verified_total. Sem tenant nem connection_id nos rótulos, por cardinalidade.
  • Traces: o executor cria o span psp.<op> e envia traceparent; o provedor continua o trace e cria upstream.<op> para o parceiro. O traceparent não sai para o parceiro, que é terceiro.
  • Logs em JSON com trace_id, span_id, connection_id, provider, op e outcome; 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_url de environments[mode].base_url (ou de um valor de config com host em egress_hosts). Fora de prd, nunca usa a URL live, salvo na exceção da tabela.
  • credential_mode com {field: "test_credential", test_value: true} exige a credencial de teste em sandbox e de produção em live. O provedor PODE recusar também pelo padrão do token, com environment_forbidden.
  • Defesa em profundidade: o provedor recusa live com runtime_env diferente de prd (salvo política permissiva e context.live_exception: true) e recusa sandbox em prd.
  • 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 404 HTTP para saque desconhecido em payouts/get: o correto é 200 com state: not_found.
  • Tratar upstream_rejected do parceiro em criação de saque como definitivo no provedor: quem decide ambiguidade é o executor.
  • Responder 200 ao callback sem verificar a assinatura sobre os bytes crus.
  • Colocar traceparent ou credencial em chamadas ao parceiro, ou em logs.

Próximos passos