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

# Guia do desenvolvedor

> Implemente integrações do Bank Transfer corretamente: idempotência, estratégia de novas tentativas, tratamento de estado e padrões de validação de webhook para transferências confiáveis.

Este guia é para desenvolvedores que implementam a integração com o plugin Bank Transfer. Ele cobre os padrões e as decisões que vão além de chamadas isoladas a endpoints: idempotência, estratégia de novas tentativas, tratamento de estado e validação de webhook.

Para parâmetros de endpoint e schemas de resposta, veja a [Referência da API](https://docs.lerian.studio/pt/reference/interfaces/ted-jd/initiate-transfer.md).

## Idempotência

***

Cada requisição que altera dados (initiate, process, cancel, return) exige um header `X-Idempotency`. Uma requisição sem ele é recusada com `400 BTF-0001`. Se você enviar a mesma requisição duas vezes com uma mesma chave, o plugin retorna a resposta original sem criar uma operação duplicada.

**Regras:**

* Use um UUID v4 ou um identificador de negócio único (por exemplo, o ID do pedido no seu sistema)
* Comprimento máximo: 255 caracteres, formados por letras, dígitos, `.`, `_` e `-`
* O plugin dá a cada chave o escopo da organização efetiva. A mesma chave vinda de duas organizações conta como duas requisições distintas.
* O plugin repete uma resposta de sucesso por 7 dias. A repetição traz o mesmo código de status e o mesmo corpo, mais o header `X-Idempotency-Replayed: true`.
* Uma resposta `4xx` não consome a chave: corrija a requisição e envie de novo com a mesma chave.
* Depois de uma resposta `5xx`, o resultado é desconhecido: leia o recurso primeiro e envie a requisição de novo apenas com uma nova chave. Uma chave que a resposta bloqueou (`X-Idempotency-Fenced: true`) é recusada com `422 BTF-0014`. Uma resposta `503 BTF-9000` ou `503 BTF-9017` não executou nada: envie a requisição de novo com a mesma chave.
* A mesma chave com um corpo diferente é recusada com `422 BTF-0017`. Um novo envio enquanto a primeira requisição ainda está em execução recebe `409 BTF-0016` com `Retry-After`.
* Cada recusa de idempotência informa sua solução em `detail`. Leia-a antes de enviar de novo.

```http theme={null}
POST /v1/transfers/initiate
X-Organization-Id: 019c9ac2-3f5d-7df9-9215-bdccc1451def
X-Idempotency: 7f3d9a1b-4e2c-4f8a-b3d1-9e6f2a4c8b7e
```

> [!WARNING]
>
> Não reutilize chaves de idempotência entre operações diferentes. Não reutilize uma chave de initiate para processar ou cancelar a mesma transferência.

### Detecção de duplicatas

Além das chaves de idempotência, o plugin detecta duplicatas por conteúdo. Ele monta uma impressão digital a partir de:

* `senderAccountId`
* dados do destinatário (ISPB, agência, conta, documento do titular)
* valor
* finalidade

O plugin guarda a impressão digital no Redis por 5 minutos. O padrão é 300 segundos. Os operadores ajustam isso por tenant pela configuração `idempotency.duplicate_guard_ttl_seconds` do systemplane. A organização não faz parte da impressão digital. O isolamento de tenant vem do prefixo da chave no Redis. O plugin rejeita a requisição com `409 BTF-0012` se o cliente já enviou uma transferência correspondente dentro da janela.

Isso pega os casos em que o cliente envia a mesma transferência com uma chave de idempotência diferente. Um exemplo é uma nova tentativa após um timeout, quando o cliente não recebeu a resposta original.

## Estratégia de novas tentativas

***

Use backoff exponencial para erros transitórios. Não repita a tentativa em todo erro.

| Status HTTP | Nova tentativa? | Observações |
| - | - | - |
| `400` | Não | Erro de validação. Corrija a requisição antes de tentar de novo |
| `404` | Não | Não encontrado. O recurso não existe |
| `409` | Depende | `BTF-0016`: a primeira requisição ainda está em execução; envie de novo com a mesma chave depois de `Retry-After`. Qualquer outro `409`: não envie de novo |
| `410` | Não | Expirado. Crie uma nova iniciação |
| `422` | Não | Regra de negócio (horário de funcionamento, limites) ou recusa de idempotência. A condição deve mudar primeiro |
| `429` | Sim | Rate limit. Aguarde o valor do header `Retry-After` (em segundos) |
| `5xx` | Leia primeiro | O resultado é desconhecido. Leia a transferência e então tente de novo com backoff, com uma nova chave, apenas se a operação não aconteceu. Uma resposta `503 BTF-9000` ou `503 BTF-9017` não executou nada: tente de novo com a mesma chave |

**Cronograma de backoff recomendado para 5xx:** 0s, 5s, 25s, 60s, 120s (5 tentativas no total).

> [!NOTE]
>
> Durante `POST /v1/transfers/process`, quando o JD SPB está indisponível, a resposta é `HTTP 503` com `code` `BTF-1000`, ou `BTF-1003` em um timeout da JD. `upstream.code` carrega o código próprio da JD quando a JD retornou um. `transferId` e `transferStatus` identificam a transferência. Uma transferência em `PROCESSING` mantém sua retenção enquanto a conciliação pergunta o resultado à JD: não a processe de novo.

## Tratamento de estado

***

### Máquina de estados da TED OUT

As transferências seguem uma progressão estrita. Você não pode cancelar uma transferência depois que ela sai de `CREATED` ou `PENDING`.

![Máquina de estados da TED OUT](https://mintcdn.com/lerian-49cb71fc/mQLt-YZp8XbLULFD/images/pt/d2/ted-state-machine-ted-out.svg?fit=max\&auto=format\&n=mQLt-YZp8XbLULFD\&q=85\&s=b4c97c53cceec9cd89418c7d5b110dea)

**O que fazer em cada estado:**

| Estado | Significado | Ação recomendada |
| - | - | - |
| `CREATED` | Confirmada pelo usuário, na fila para envio | Mostre "Processando" na interface; consulte ou aguarde o webhook |
| `PENDING` | O plugin prepara o envio à JD | Mostre "Processando"; o cancelamento ainda é possível |
| `PROCESSING` | Enviada à JD, aguardando o resultado da liquidação | Mostre "Processando"; não permita cancelamento. Se `reconciliationStatus` for `MANUAL_REVIEW`, a retenção continua em vigor e um operador resolve a transferência |
| `COMPLETED` | Liquidada | Mostre a confirmação com `confirmationNumber` |
| `REJECTED` | A JD ou o STR rejeitou a transferência | Mostre o erro ao usuário; os recursos já foram liberados |
| `FAILED` | O ledger recusou a retenção, ou a conciliação não conseguiu liquidar a transferência | Mostre o erro. Se `reconciliationStatus` for `MANUAL_REVIEW`, a retenção continua em vigor e um operador ainda pode liquidar a transferência: não ofereça uma nova transferência até a resolução |
| `CANCELLED` | Cancelada antes do envio | Mostre a confirmação do cancelamento |

### Máquina de estados da iniciação

O endpoint initiate cria uma entidade `PaymentInitiation`. Essa entidade tem seu próprio ciclo de vida antes de o plugin criar um `Transfer`.

![Máquina de estados da iniciação](https://mintcdn.com/lerian-49cb71fc/acz_224gNiM7aYvL/images/pt/d2/ted-state-machine-initiation.svg?fit=max\&auto=format\&n=acz_224gNiM7aYvL\&q=85\&s=e18de4103828f31835b4f71215a6be82)

### Máquina de estados da TED IN

![Máquina de estados da TED IN](https://mintcdn.com/lerian-49cb71fc/acz_224gNiM7aYvL/images/pt/d2/ted-state-machine-ted-in.svg?fit=max\&auto=format\&n=acz_224gNiM7aYvL\&q=85\&s=818a9b698fcf4d6b2a7ce08b0f89f944)

### Máquina de estados do P2P

O P2P não tem estado `PENDING`. A liquidação é atômica e instantânea.

![Máquina de estados do P2P](https://mintcdn.com/lerian-49cb71fc/mQLt-YZp8XbLULFD/images/pt/d2/ted-state-machine-ted-p2p.svg?fit=max\&auto=format\&n=mQLt-YZp8XbLULFD\&q=85\&s=e571502db058bb06e977c608ff5a9b01)

### Polling vs. webhooks

Prefira webhooks para status em tempo real. Se você ainda não configurou webhooks, consulte `GET /v1/transfers/{transferId}`. Use no máximo 10 tentativas com o mesmo cronograma de backoff das novas tentativas. Sinalize a transferência para revisão manual depois de 10 minutos sem estado terminal (`COMPLETED`, `REJECTED`, `FAILED`, `CANCELLED`).

Veja [Obter transferência](https://docs.lerian.studio/pt/reference/interfaces/ted-jd/retrieve-transfer.md) e [Webhooks](https://docs.lerian.studio/pt/interfaces/ted-jd/ted-webhooks.md).

## Integração de webhook

***

Para os schemas de payload dos eventos e a lista completa de eventos, veja [Webhooks](https://docs.lerian.studio/pt/interfaces/ted-jd/ted-webhooks.md).

### Validação da assinatura

Cada requisição de webhook inclui headers que seu endpoint usa para verificar a autenticidade:

* `X-Webhook-Signature`: assinatura HMAC-SHA256 versionada no formato `v1,sha256=<hex>`
* `X-Webhook-Timestamp`: timestamp Unix em segundos (UTC) de quando o plugin montou a requisição
* `X-Webhook-Event`: o tipo do evento (por exemplo, `transfer.completed`). Este header não faz parte da assinatura.

O plugin calcula a assinatura assim:

```
X-Webhook-Signature: v1,sha256=hex(HMAC_SHA256(signingSecret, "v1:" + <timestamp> + "." + <raw_body>))
```

A string assinada tem quatro partes, nesta ordem: o prefixo `v1:`, o valor do timestamp vindo de `X-Webhook-Timestamp`, um ponto ASCII (`.`) e então os bytes brutos do corpo da requisição. Use os bytes do corpo exatamente como chegam pela rede. Não os analise nem os recodifique antes.

Para validar:

1. Leia `X-Webhook-Signature` e `X-Webhook-Timestamp` nos headers da requisição.
2. Monte a string assinada: `"v1:" + timestamp + "." + rawBody`.
3. Calcule `HMAC-SHA256` sobre a string assinada com o `signingSecret` do registro do webhook e codifique o resultado em hexadecimal.
4. Coloque `v1,sha256=` na frente e compare com `X-Webhook-Signature` usando uma função de igualdade de tempo constante.
5. Rejeite a requisição se o timestamp estiver fora de uma janela de validade aceitável (uma tolerância de 5 minutos é típica) para evitar replay.

> [accordion] JavaScript
>
> ```javascript theme={null}
> const crypto = require('crypto');
> const express = require('express');
>
> const TOLERANCE_SECONDS = 300; // 5 minutes
>
> function validateWebhook(rawBody, timestamp, signature, secret) {
>   if (!timestamp || !signature) return false;
>
>   const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
>   if (Number.isNaN(ageSeconds) || ageSeconds > TOLERANCE_SECONDS) return false;
>
>   const signedPayload = Buffer.concat([
>     Buffer.from('v1:', 'utf8'),
>     Buffer.from(timestamp, 'utf8'),
>     Buffer.from('.', 'utf8'),
>     rawBody,
>   ]);
>
>   const expected = 'v1,sha256=' + crypto
>     .createHmac('sha256', secret)
>     .update(signedPayload)
>     .digest('hex');
>
>   const expectedBuf = Buffer.from(expected);
>   const receivedBuf = Buffer.from(signature);
>   if (expectedBuf.length !== receivedBuf.length) return false;
>
>   return crypto.timingSafeEqual(expectedBuf, receivedBuf);
> }
>
> // Use raw body — not req.body (parsed JSON)
> app.post('/webhooks/ted',
>   express.raw({ type: 'application/json' }),
>   (req, res) => {
>     const timestamp = req.headers['x-webhook-timestamp'];
>     const signature = req.headers['x-webhook-signature'];
>
>     if (!validateWebhook(req.body, timestamp, signature, process.env.TED_WEBHOOK_SIGNING_SECRET)) {
>       return res.status(401).send('Invalid signature');
>     }
>
>     const payload = JSON.parse(req.body.toString());
>     // process payload...
>     res.status(200).send('OK');
>   }
> );
> ```

> [accordion] Python
>
> ```python theme={null}
> import hmac
> import hashlib
> import time
>
> TOLERANCE_SECONDS = 300  # 5 minutes
>
> def validate_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
>     if not timestamp or not signature:
>         return False
>
>     try:
>         age = abs(int(time.time()) - int(timestamp))
>     except ValueError:
>         return False
>     if age > TOLERANCE_SECONDS:
>         return False
>
>     signed_payload = b"v1:" + timestamp.encode() + b"." + raw_body
>     expected = "v1,sha256=" + hmac.new(
>         secret.encode(),
>         signed_payload,
>         hashlib.sha256,
>     ).hexdigest()
>
>     return hmac.compare_digest(expected, signature)
> ```

> [accordion] Go
>
> ```go theme={null}
> import (
>     "crypto/hmac"
>     "crypto/sha256"
>     "encoding/hex"
>     "strconv"
>     "time"
> )
>
> const toleranceSeconds = 300 // 5 minutes
>
> func validateWebhook(rawBody []byte, timestamp, signature, secret string) bool {
>     if timestamp == "" || signature == "" {
>         return false
>     }
>
>     ts, err := strconv.ParseInt(timestamp, 10, 64)
>     if err != nil {
>         return false
>     }
>     if diff := time.Now().Unix() - ts; diff < -toleranceSeconds || diff > toleranceSeconds {
>         return false
>     }
>
>     mac := hmac.New(sha256.New, []byte(secret))
>     mac.Write([]byte("v1:"))
>     mac.Write([]byte(timestamp))
>     mac.Write([]byte("."))
>     mac.Write(rawBody)
>     expected := "v1,sha256=" + hex.EncodeToString(mac.Sum(nil))
>
>     return hmac.Equal([]byte(expected), []byte(signature))
> }
> ```

### Processamento idempotente de webhook

Seu endpoint pode receber o mesmo evento mais de uma vez (entrega pelo menos uma vez). Use `transferId` + o header `X-Webhook-Event` como chave composta para deduplicar. Para os eventos de devolução de um TED IN, adicione `status` e `reconciliationReasonCode` do corpo à chave.

```javascript theme={null}
const alreadyProcessed = await db.webhookEvents.exists({
  transferId: payload.transferId,
  event: req.headers['x-webhook-event'],
  status: payload.status, // return events of a TED IN
  reconciliationReasonCode: payload.reconciliationReasonCode,
});

if (alreadyProcessed) {
  return res.status(200).send('OK'); // acknowledge without reprocessing
}
```

## Padrões de tratamento de erros

***

Mapeie os códigos de erro da API para ações voltadas ao usuário. Veja a [lista completa de erros](https://docs.lerian.studio/pt/reference/interfaces/ted-jd/ted-error-list.md) para todos os códigos.

| Cenário | Mensagem para o usuário | Ação |
| - | - | - |
| **Fora do horário de funcionamento** (`BTF-0010`) | "Transferências disponíveis de seg. a sex., 06:30–17:00 (Brasília). Próxima janela: {time}" | Mostre o próximo horário disponível |
| **Limite de uso excedido** (`BTF-0011`) | "Limite de transferência atingido para este período." | Mostre `period`, `limit` e `current` do erro |
| **Transferência duplicada** (`BTF-0012`) | "Esta transferência já foi enviada." | Encontre a transferência anterior com List Transfers |
| **Dados do destinatário inválidos** (`BTF-0001`) | "Verifique os dados do destinatário e tente de novo." | Destaque os campos inválidos |
| **Iniciação expirada** (`BTF-0202`) | "Sessão expirada. Comece uma nova transferência." | Reinicie o fluxo de iniciação |
| **JD SPB indisponível** (`BTF-1000` ou `BTF-1003`, HTTP `503`) | "Serviço de transferência temporariamente indisponível. Manteremos você informado." | Leia a transferência indicada em `transferId`; não a processe de novo |
| **Midaz indisponível** (`BTF-2000`) | "Serviço temporariamente indisponível. Tente de novo em alguns minutos." | Nova tentativa com backoff |

As respostas de erro são documentos de problema RFC 9457 (`application/problem+json`). Os campos específicos de cada rota ficam no nível superior:

```json theme={null}
{
  "type": "https://errors.lerian.studio/v1/BTF-0010",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "transfers can only be initiated Monday-Friday between 06:30 and 17:00 America/Sao_Paulo time",
  "instance": "4bf92f3577b34da6a3ce929d0e0e4736",
  "code": "BTF-0010",
  "requestId": "6d3e2a68-1f2b-4c3d-9e4f-5a6b7c8d9e0f",
  "service": "plugin",
  "category": "deterministic",
  "currentTime": "2026-01-21T18:30:00-03:00",
  "nextAvailableTime": "2026-01-22T06:30:00-03:00",
  "openTime": "06:30",
  "closeTime": "17:00",
  "timezone": "America/Sao_Paulo"
}
```

## Checklist de entrada em produção

***

Antes de habilitar a integração em produção:

* [ ] Envie `X-Idempotency` em cada requisição de initiate, process e cancel
* [ ] Lógica de nova tentativa implementada com backoff exponencial para erros 5xx/503
* [ ] Endpoint de webhook com o deploy feito e retornando `200` em até 5 segundos
* [ ] Validação de assinatura ativa no endpoint de webhook
* [ ] Deduplicação de eventos de webhook implementada com `transferId + event`
* [ ] Horário de funcionamento validado no cliente antes de chamar initiate (reduz 422 desnecessários)
* [ ] `transferId` e `confirmationNumber` armazenados para conciliação
* [ ] Estados terminais (`COMPLETED`, `REJECTED`, `FAILED`, `CANCELLED`) tratados na interface
* [ ] Expiração da iniciação tratada (`expiresAt`): peça ao usuário para recomeçar quando ela passar
* [ ] Readiness do serviço monitorada no seu sistema de alertas para deploys BYOC
* [ ] Redis acessível e monitorado: o serviço rejeita requisições quando o Redis está fora
* [ ] `PLUGIN_AUTH_ENABLED=true` configurado em produção, com um `PLUGIN_AUTH_ADDRESS` válido (HTTPS)


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