> ## Documentation Index
> Fetch the complete documentation index at: https://docs.unifokal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Gate transacional

> O módulo transacao avalia a transação que você envia e devolve allow, step_up ou deny na hora, com as razões de cada sinal.

## Gate transacional

O módulo `transaction_gate` avalia a **transação que você nos envia** e devolve `allow`, `step_up` ou `deny`, com o risco e as razões que dispararam. 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.

> [!WARNING]
>
> **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. Fornecemos a tecnologia que instrumenta a análise do seu PSP; não participamos do circuito do MED (Regulamento do Pix, anexo à Resolução BCB nº 1, com as alterações da Resolução BCB nº 493/2025). `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 razões são categorias. Os números deste exemplo são ilustrativos.
{ "module": "transaction_gate", "passed": null, "outcome": "pending", "score": 40,
  "data": { "transaction": {
      "verdict": "step_up",              // allow | step_up | deny
      "risk_score": 70,                  // 0..100 (100 é o PIOR)
      "reasons": ["amount_above_profile", "new_counterparty", "night_window"],
      "reasons_expired": false,          // true depois de 180 dias da decisão, com a lista vazia
      "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, e o risk_score 100 é carimbo, não cálculo.
{ "module": "transaction_gate", "passed": false, "outcome": "failed", "score": 0,
  "data": { "transaction": { "verdict": "deny", "risk_score": 100,
                             "reasons": ["blocklist_hit"],
                             "confidence": 0.3,
                             "feature_set_version": "tx-fs_90adbec7ef08",
                             "calibration_version": "tx-cal_9f2c1a4b7e03" } } }

// allow, perfil maduro e payload completo -> confidence alta
{ "module": "transaction_gate", "passed": true, "outcome": "approved", "score": 100,
  "data": { "transaction": { "verdict": "allow", "risk_score": 0,
                             "reasons": [], "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 unifokal_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" },
  "transaction_gate": { "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.

**O nível do gate.** Na tela **Motor** do painel, você escolhe o nível deste gate: **Conservador**, **Equilibrado** ou **Conversão**, do mais rigoroso ao que mais converte. O Conservador é o padrão. Veja o efeito no seu histórico antes de ligar, e observe em sombra sem mudar nenhuma decisão. No sandbox o veredito continua saindo do valor, e o nível não muda nada.

**Step-up com prova de humano.** 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 `transaction_gate` 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.

**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. Preencha o `occurred_at` com a hora que o seu servidor registrou, nunca com a hora que o aparelho do titular informa.

**`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: nada é tratado como "limpo por omissão". Quem somar o tamanho de `reasons` como se fosse risco erra. O risco é o `risk_score`, de 0 a 100.

**`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 de quão recente é a última atividade dele. 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.

**`reasons` fica disponível por 180 dias contados da decisão.** Depois disso, a consulta pelo painel, a reemissão e a exportação trazem o veredito, o risco, a confiança e as versões, com `reasons` vazia e `reasons_expired: true` (antes do prazo ele vem `false`, sempre presente). O corpo guardado de cada entrega sai junto, no mesmo prazo: depois dele, o replay e o reenvio pelo painel respondem `payload_unavailable`. Lista vazia com `reasons_expired: true` querem dizer que as razões saíram no prazo, nunca que nada disparou. No mesmo prazo a referência sai da verificação do evento: a consulta e a exportação trazem `reference_id: null`, e a busca pela referência deixa de achar essa verificação; o `verification_id` não muda, e o evento de transação segue com o prazo dele. Guarde no recebimento do webhook o que precisar guardar por mais tempo, inclusive para a avaliação anual de efetividade, se o seu regime a pede.

**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. Use o id do usuário no seu sistema, por exemplo um UUID. `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 leva dado pessoal: CPF, CNPJ, telefone, e-mail ou chave Pix nunca entram, com ou sem máscara. Espaço nas pontas e caractere invisível não contam: a forma comparada e guardada é a canônica. Um caractere fora do conjunto aceito é `422 reference_id_charset` (a mensagem nomeia o campo recusado). No `reference_id` e no `counterparty_ref`, que identificam pessoas, um valor com forma de dado pessoal é `422 pii_shaped_value`; no `counterparty_ref` isso inclui a forma de chave Pix aleatória, então um UUID seu ali vai com prefixo ou como HMAC. 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://docs.unifokal.com/pld-ft.md#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`; acima do teto que você configurou no flow (campo `max_amount_cents`, em centavos, que só aperta o de fábrica) é `422 amount_above_flow_ceiling`; 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. Guardamos que o titular foi aprovado no onboarding por 180 dias contados da última aprovação e, depois disso, enquanto ele tiver histórico de conta ou de transação com você. Quem fez onboarding há mais de 180 dias e ficou sem atividade desde então volta a receber `no_verified_identity`, e um novo onboarding reabre o registro.

```
// Os treze 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 amount_above_flow_ceiling       acima do teto que a conta configurou no flow (max_amount_cents)
//   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
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.