# Gate transacional

<https://unifokal.com/docs/modulos/transacao>

## Gate transacional

O módulo `transacao` avalia a **transação que você nos envia** e devolve `allow`, `step_up` ou `deny`, com as razões e os pontos de risco de cada sinal que disparou. Ele olha valor fora do padrão do próprio titular, cadência, contraparte nova ou concentradora, troca de aparelho, horário, e se aquele titular já foi verificado por você aqui. Roda sobre o **seu** histórico, sem consórcio com outros clientes.

! **Quem decide liberar o pagamento é você.** Nós não liquidamos, não acessamos o DICT nem o MED, e não somos o arranjo Pix. `deny` é a nossa **recomendação**, não um bloqueio: o módulo é portão suave e nunca recusa a verificação sozinho.

**Disponível desde 11 de setembro de 2026.** Ele aparece na tabela de preços e no `GET /v1/capabilities` com preço e status `available`, pode ser ligado num flow, e a ingestão de transações deixou de responder `422 transaction_not_supported` em flow que o contenha. Até essa data as duas travas eram o preço e o registro do módulo como consumidor de transação, e elas caíram juntas: abrir só uma teria posto o módulo à venda com a porta de entrada ainda fechada.

**Num flow que contenha o gate, o lote não é aceito: `transactions[]` responde `422 batch_not_supported_for_gate`**, e só a forma unitária `transaction` é avaliada. A razão é de produto: um gate síncrono decide **um** pagamento, e um lote não teria veredito. Isso não quebra integração nenhuma, e o argumento é verificável: até esta data nenhum flow podia conter o módulo, porque a criação de flow o recusava, então a regra nasce junto com a possibilidade.

**Só o movimento que o próprio titular iniciou, e que não falhou, recebe veredito.** São `deposit`, `withdraw`, `transfer`, `payment` e `bet`, com `status` `confirmed` ou `pending`. `settlement` e `reversal` são o ciclo de vida de um pagamento que já foi julgado, `bet_profit` e `bet_loss` são o resultado que a casa apurou, `status: failed` é dinheiro que não se moveu, e o evento de backfill que chega fora da janela é um pagamento que você já liquidou. Nenhum deles tem uma pergunta em aberto: todos continuam sendo ingeridos normalmente, entram na trilha, na retenção e no monitoramento, e simplesmente não geram verificação nem cobrança.

**Duas escalas convivem aqui, e elas apontam para lados opostos.** `risk_score` vai de 0 a 100 com **100 sendo o pior**, e é o eixo do gate. O `score` do check ao lado é o de negócio, e é **ternário e fixo**: `allow` vale 100, `step_up` vale 40 e `deny` vale 0. Um não é o complemento do outro, e a conta `100 menos risk_score` **não** reproduz o segundo.

```
// transacao: step_up. As contribuições dizem quantos pontos de risco cada sinal que
// disparou somou. Os números deste exemplo são ilustrativos.
{ "module": "transacao", "passed": null, "outcome": "pending", "score": 40,
  "data": { "transaction": {
      "verdict": "step_up",              // allow | step_up | deny
      "risk_score": 70,                  // 0..100 (100 é o PIOR) = a SOMA das contributions, com teto em 100
      "reasons": ["amount_above_profile", "new_counterparty", "night_window"],
      // ordenadas por pontos desc; empate desempata pelo nome do sinal, em ordem alfabética
      "contributions": [ { "signal": "amount_above_profile", "points": 31 },
                         { "signal": "new_counterparty", "points": 22 },
                         { "signal": "night_window", "points": 17 } ],
      "confidence": 0.3,                 // 0..1, e NÃO é probabilidade de fraude
      "feature_set_version": "tx-fs_90adbec7ef08",
      "calibration_version": "tx-cal_9f2c1a4b7e03" } } }

// deny: é PORTÃO determinístico, não soma de pesos. Por isso "contributions" vem vazio
// e o risk_score 100 é carimbo, não cálculo.
{ "module": "transacao", "passed": false, "outcome": "failed", "score": 0,
  "data": { "transaction": { "verdict": "deny", "risk_score": 100,
                             "reasons": ["blocklist_hit"], "contributions": [],
                             "confidence": 0.3,
                             "feature_set_version": "tx-fs_90adbec7ef08",
                             "calibration_version": "tx-cal_9f2c1a4b7e03" } } }

// allow, perfil maduro e payload completo -> confidence alta
{ "module": "transacao", "passed": true, "outcome": "approved", "score": 100,
  "data": { "transaction": { "verdict": "allow", "risk_score": 0,
                             "reasons": [], "contributions": [], "confidence": 1,
                             "feature_set_version": "tx-fs_90adbec7ef08",
                             "calibration_version": "tx-cal_9f2c1a4b7e03" } } }
```

**A resposta síncrona sempre vem, e para a mesma pergunta ela é sempre a mesma.** Num flow com o gate, todo evento avaliável recebe `verdict` na própria chamada. Isso inclui o seu **retry**: repetir o mesmo `external_id` devolve o veredito que já foi dado na primeira vez, sem reavaliar e **sem uma segunda cobrança**. É o comportamento que um caminho de pagamento precisa, porque o retry de rede ali é rotina e não exceção. O `external_id` é único por organização e ambiente, e o veredito dele também: se o mesmo `external_id` for enviado por outro flow seu, a resposta é o veredito que já foi dado, sem nova avaliação e sem nova cobrança.

**No sandbox o gate também decide**, com a mesma forma de resposta da produção e sem gravar nada. O veredito de sandbox é determinístico e você escolhe o ramo pelos **dois últimos dígitos** de `amount_cents`. Ele sai só do valor: no sandbox o gate não consulta perfil nem lista de bloqueio, porque ali ele existe para você exercitar os três ramos do seu código, e não para julgar titular.

```
// SANDBOX: o veredito sai do valor, para você exercitar os três ramos do seu código.
// ...13  -> deny      (ex.: amount_cents 10013, 4513, 13)
// ...07  -> step_up   (ex.: amount_cents 10007, 4507, 7)
// resto  -> allow

POST /v1/verification-sessions   (chave sk_test_)
{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "payment", "amount_cents": 10007, "external_id": "pay-1" } }

201
{ "id": "ing_...", "status": "consumed",
  "ingest": { "batch_id": "ing_...", "accepted": 1, "duplicated": 0, "batch_size": 1,
              "first_seen_at": "2026-09-20T12:00:00.000Z" },
  "transacao": { "verdict": "step_up" } }
```

**Trate `step_up` como o caminho normal, não como o raro.** Ele é a categoria que pede uma prova a mais antes de liberar o dinheiro, e é sempre a resposta quando o gate prefere não afirmar. Uma integração que só trata `allow` e `deny` está com um ramo em aberto.

**Step-up com prova de humano (em breve).** Quando o flow do gate tem um flow de prova configurado (`step_up_flow_id`, com um flow de reserva opcional em `step_up_fallback_flow_id`) e o veredito é `step_up`, o bloco `transacao` passa a trazer `step_up`. Com `available: true`, `session_id` é a sessão de PROVA, válida por 600 segundos: monte o widget com ela, como na criação de sessão, e o titular aprova o pagamento com a passkey da conta dele ou com a reautenticação facial, conforme o fator que a conta tem (`factor`). O resultado chega no webhook da sessão de prova, com `data.step_up_of`: `source` é o gate que pediu a prova, `verification_id` é a verificação do pagamento e `event_type` é o tipo do evento. É por ele que você liga o desfecho da prova ao pagamento que estava esperando. A verificação do pagamento não muda por causa da prova. Com `available: false`, `reason` diz por quê (por exemplo `no_factor_enrolled` ou `insufficient_credit`) e você aplica o seu próprio desafio. Repetir o mesmo `external_id` devolve o mesmo step-up enquanto o pedido estiver aberto, e `step_up_closed` depois. No sandbox a ingestão não abre sessão de prova: o bloco vem com `step_up_sandbox`, na mesma forma da produção. Os módulos de prova estão com a venda pausada, então a configuração só fica disponível quando eles abrirem.

**A sua lista de bloqueio vale aqui, em produção.** Quando o `reference_id` da transação está na lista, o veredito é `deny`, com `blocklist_hit` em `reasons`. Envie sempre o `reference_id` do titular junto da transação: é por ele que a sua lista é consultada. No sandbox, como o veredito vem do valor, use o gatilho de `amount_cents` para exercitar o ramo de `deny` do seu código.

**O `reference_id` e o `counterparty_ref` são o **sujeito** e o **destino**, e nos dois a maiúscula não muda quem é quem.** `User_42`, `user_42` e `USER_42` são a mesma pessoa, e o mesmo vale para o destino: o histórico, a janela de análise e a sua lista de bloqueio enxergam um titular só. Isso importa porque um id que muda de grafia entre dois caminhos do seu backend (um derivado de e-mail, outro digitado num formulário) partiria o histórico em dois pedaços pequenos, e histórico curto derruba a `confidence` do veredito. O `external_id` é o oposto e de propósito: ele é o token do **evento**, comparado byte a byte, então `TX-1` e `tx-1` são duas transações diferentes.

**A janela de análise é contada pelo relógio do nosso servidor.** O `occurred_at` que você manda continua valendo, e é ele que descreve quando o pagamento aconteceu no seu sistema; quem decide em que janela o evento entra é o momento em que ele chega aqui. Consequência prática para a sua integração: se você faz carga retroativa, mande-a de uma vez e não espere que ela reescreva a análise de semanas passadas, porque a análise é do que chegou.

**`reasons` pode conter razão que não pontua, e isso é de propósito.** Uma razão pode declarar **contexto** sobre a avaliação sem somar risco, e nesse caso ela aparece em `reasons` e não em `contributions`: nada é tratado como "limpo por omissão". Quem somar o tamanho de `reasons` como se fosse risco erra. A conta de verdade é `contributions[].points`: os pontos de risco de cada sinal que disparou, e a soma deles, com teto em 100, é o `risk_score`.

**`confidence` não é probabilidade de fraude.** É o quanto o veredito merece crédito, composto de maturidade do perfil daquele titular, completude do payload que você mandou e nitidez da contraparte. Perfil novo derruba a confiança mesmo com veredito `allow`. Use os dois juntos: `step_up` com `confidence` baixa é "desconfio, mas sei pouco", e merece tratamento diferente de `step_up` com confiança alta.

**O que não sai daqui, e é deliberado:** o limiar que separa as bandas, e o valor, a contraparte e o documento da transação. Publicar o limiar vigente transformaria o payload num oráculo contra o próprio gate. Pela mesma razão, a resposta **síncrona** da ingestão devolve o `verdict` e nada mais: as razões e as contribuições viajam só aqui, no webhook, que é superfície assinada e auditável. E `calibration_version` é o rastro dessa calibração: `tx-cal_` mais doze caracteres em produção, e a string literal `"sandbox"` no ambiente de testes.

**`feature_set_version` e `calibration_version` são strings **opacas**: guarde, nunca interprete.** Elas servem para uma coisa só, e é uma coisa valiosa: reler um veredito de meses atrás sabendo que ele saiu sob exatamente aquela política. O prefixo é estável (`tx-fs_` e `tx-cal_`); o que vem depois muda sempre que a política muda, e não carrega significado que você possa decodificar. Os valores que aparecem nos exemplos acima são **ilustrativos**: não compare o seu payload com eles, não os fixe em teste e não condicione comportamento a um valor específico. O que você deve fazer é guardá-los junto do desfecho, e comparar um veredito com outro veredito.

**O contrato do evento: o ciclo de vida.** Um movimento que você nos manda com `status` `confirmed` (o padrão) é um movimento que aconteceu. `settlement` liquida um evento seu que chegou `pending`, e quem decide a liquidação é o banco de dados, nunca a ordem de chegada: a segunda liquidação do mesmo alvo responde `409 already_settled`, e uma liquidação cujo `settles` não aponta um evento seu, no mesmo ambiente, responde `422 unknown_settles_reference`. Liquidação e reversão nunca chegam pendentes: `status: pending` em uma delas é `422 settlement_status_invalid`. `reversal` nunca altera o evento revertido, que continua contando como o movimento que de fato aconteceu; a reversão é um evento próprio, e ela conta na próxima avaliação daquele titular. Em qualquer um dos dois, `settles` é o `external_id` do alvo. E `status: pending` só entra em flow cujo módulo aceite o ciclo pendente; nos demais ele responde `422 pending_lifecycle_not_enabled`. Hoje nenhum dos módulos que consomem transação aceita o ciclo pendente: mande o evento já confirmado e, se ele for desfeito, mande a reversão.

```
// Reversão: um evento próprio, que aponta o alvo por settles (o external_id do alvo).
// O alvo não muda; a reversão conta na próxima avaliação deste titular.
POST /v1/verification-sessions
{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "payment", "amount_cents": 64000, "external_id": "pay-77",
                   "counterparty_ref": "loja-903" } }

{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "reversal", "amount_cents": 64000, "external_id": "pay-77-rev",
                   "settles": "pay-77" } }

// Liquidação: só de um alvo que chegou pending, em flow cujo módulo aceite o ciclo pendente.
{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "settlement", "amount_cents": 64000, "external_id": "dep-12-liq",
                   "settles": "dep-12" } }
// segunda liquidação ou reversão do mesmo alvo -> 409 already_settled
// settles sem evento seu no mesmo ambiente     -> 422 unknown_settles_reference
// liquidação ou reversão com status pending    -> 422 settlement_status_invalid
```

**Os três ids são opacos, e cada um tem um papel.** `reference_id` é o **titular**, e precisa ser estável por pessoa: é por ele que o histórico, o perfil e a sua lista de bloqueio são lidos. `external_id` é o token de idempotência do **evento**, comparado byte a byte: é ele que faz o seu retry devolver o mesmo veredito sem uma segunda cobrança, e ele é obrigatório (sem ele, `422 external_id_required`). `counterparty_ref` é o **destino**. Nenhum dos três aceita dado pessoal: um caractere fora do conjunto aceito é `422 reference_id_charset` (a mensagem nomeia o campo recusado), e um valor com forma de dado pessoal é `422 pii_shaped_value`. Número de pessoa (CPF, telefone) nunca é referência, nem escrito sem máscara: use um id do seu sistema. O corpo do erro nunca devolve o valor recusado, só o campo e a posição no lote.

**`device` é minimizado antes de ser guardado.** Do IP fica só o prefixo de rede (/24 em IPv4, /48 em IPv6) e um derivado comparável; do fingerprint, só o derivado. Nem o IP nem o fingerprint ficam em claro, e a telemetria de aparelho tem prazo próprio, mais curto que o do evento. Mande os dois quando tiver: eles alimentam os sinais de aparelho da avaliação e nada além disso.

**`direction` diz para que lado o dinheiro foi, do ponto de vista do titular.** `in` entrou na conta dele, `out` saiu. O campo é opcional e, ausente, fica registrado como não informado: nós nunca deduzimos a direção do `type`, porque uma `transfer` pode ser as duas coisas. Qualquer outro valor é `400 validation_error`. No monitoramento de PLD/FT, uma `transfer` ou um `payment` sem `direction` conta como saída: mande `in` quando o titular recebe.

**`instrument` identifica o meio de pagamento do titular, e nunca envie o número do cartão.** O bloco tem `kind` (`card`, `account`, `wallet` ou `pix_key`) e `ref`, um valor opaco seu: o token que o seu PSP já devolve, ou um HMAC que você calcula. Guardamos só o tipo e um derivado comparável do `ref`, nunca o valor que chegou. Um `ref` com forma de número de cartão (13 a 19 dígitos que fecham o dígito verificador, com ou sem separador), de CPF, CNPJ, e-mail, telefone ou chave Pix é `422 pii_shaped_value`, sem o valor no corpo do erro. Não existe campo de código de segurança nem de validade, e mandar um é `400 validation_error`. Se o seu token do PSP for só de dígitos, prefira mandar um HMAC dele: uma sequência numérica pode fechar o dígito verificador por acaso e ser recusada.

**`cash` diz que a operação foi em espécie**, dinheiro vivo. É booleano, o padrão é `false`, e não existe outro jeito de marcar espécie: o `method` continua sendo o meio eletrônico. Mande `true` só quando a operação foi mesmo em espécie, porque é ele que as regras de espécie da [PLD/FT pela regra da norma](https://unifokal.com/docs/pld-ft#pld-ft) leem.

```
{ "flow_id": "flow_...", "reference_id": "cliente-4821",
  "transaction": { "type": "transfer", "amount_cents": 64000, "external_id": "tr-91",
                   "direction": "out", "cash": false,
                   "instrument": { "kind": "card", "ref": "tok_1Nv0aB2eZvKYlo2C" } } }
```

**Os tetos, cada um com o seu código.** `amount_cents` acima do teto de fábrica é `422 amount_too_large`; moeda diferente de `BRL` é `422 currency_not_supported`; `occurred_at` além da janela de importação é `422 event_too_old` (dentro dela, o evento antigo entra normalmente e não recebe veredito, como dito acima); e o teto diário de eventos por organização é `429 daily_ingest_cap_reached`, sem `Retry-After` porque o balde é o dia: pause, retome no dia seguinte, e saiba que o lote recusado não gravou nada. Num flow que contenha o gate, a chamada **sem** o bloco `transaction` responde `422 transaction_required`, tanto na criação de sessão quanto na emissão de link hospedado: esse flow só recebe transação, e uma sessão de widget não teria o que perguntar a ele.

**O flow do gate é um flow só dele.** `POST /v1/flows` e `PATCH /v1/flows/{id}` recusam com `422 sync_gate_module_exclusive` uma composição que junte o gate a qualquer outro módulo. A razão é a mesma que faz o gate decidir na própria chamada: a requisição que carrega o bloco `transaction` responde na hora e não abre jornada de captura, então um módulo de documento ou de biometria nesse flow nunca teria por onde rodar. Mantenha o seu KYC no flow que você já tem e crie um flow separado só com o gate. Os dois flows convivem no mesmo `reference_id`: é por ele que o gate encontra a identidade aprovada do titular.

```
// Os doze códigos da ingestão de transação, além dos da criação de sessão:
//   422 transaction_required            flow com o gate e chamada sem o bloco (sessão e link hospedado)
//   422 external_id_required            evento sem o token de idempotência
//   422 amount_too_large                acima do teto de fábrica
//   422 currency_not_supported          só BRL
//   422 event_too_old                   occurred_at além da janela de importação
//   422 reference_id_charset            id opaco com caractere fora do conjunto aceito (a mensagem nomeia o campo)
//   422 pii_shaped_value                id opaco com forma de dado pessoal
//   422 pending_lifecycle_not_enabled   status pending em flow cujo módulo não aceita o ciclo pendente
//   422 settlement_status_invalid       liquidação ou reversão com status pending
//   422 unknown_settles_reference       settles sem evento seu no mesmo ambiente
//   409 already_settled                 segunda liquidação ou reversão do mesmo alvo
//   429 daily_ingest_cap_reached        teto diário por organização; sem Retry-After, retome no dia seguinte
```
