> ## 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.

# Guía del desarrollador

> Implementa integraciones Bank Transfer correctamente — idempotencia, estrategia de reintentos, manejo de estados y validación de webhooks confiables.

Esta guía es para desarrolladores que implementan la integración del plugin Bank Transfer. Cubre los patrones y decisiones que van más allá de las llamadas individuales a endpoints: idempotencia, estrategia de reintentos, manejo de estados y validación de webhooks.

Para los parámetros de los endpoints y los esquemas de respuesta, consulta la [Referencia de API](/es/reference/midaz/plugins/ted/initiate-transfer).

## Idempotencia

***

Cada solicitud mutante (initiate, process, cancel) requiere un header `X-Idempotency`. Si envías la misma clave dos veces, el plugin devuelve la respuesta original sin crear una operación duplicada.

**Reglas:**

* Usa un UUID v4 o un identificador de negocio único (por ejemplo, tu ID de orden interno)
* Longitud máxima: 255 caracteres
* Las claves tienen alcance según la organización efectiva — la misma clave de dos organizaciones diferentes se trata como dos solicitudes distintas
* Las respuestas en caché se devuelven durante la ventana de idempotencia configurada (`IDEMPOTENCY_RETRY_WINDOW_SEC`, por defecto 300 segundos)
* Las respuestas reemitidas son byte-idénticas a la original (mismo código de estado y mismo body); la respuesta no expone actualmente un header para distinguir replays de ejecuciones nuevas, así que diseña tu cliente para ser seguro en cualquier caso

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

<Warning>
  No reutilices claves de idempotencia en diferentes operaciones. Una clave usada para iniciar una transferencia no debe reutilizarse para procesarla o cancelarla.
</Warning>

### Detección de duplicados

Más allá de las claves de idempotencia, el plugin detecta duplicados basados en el contenido. Genera una huella (fingerprint) a partir de:

* `senderAccountId`
* los datos del destinatario (ISPB, agencia, cuenta, documento del titular)
* el monto
* el propósito

La huella se almacena en Redis durante 5 minutos (por defecto 300 segundos, configurable vía `DUPLICATE_GUARD_TTL_SEC`). La organización no forma parte de la huella — el aislamiento de tenant proviene del prefijo de clave de Redis. Si ya se envió una transferencia coincidente dentro de la ventana, la solicitud es rechazada con `409 BTF-0012`.

Esto captura los casos en que el cliente envía la misma transferencia con una clave de idempotencia diferente — por ejemplo, después de un timeout donde no se recibió la respuesta original.

## Estrategia de reintentos

***

Usa backoff exponencial para errores transitorios. No todos los errores deben reintentarse.

| Estado HTTP | ¿Reintentar? | Notas                                                                   |
| ----------- | ------------ | ----------------------------------------------------------------------- |
| `400`       | No           | Error de validación — corrige la solicitud antes de reintentar          |
| `404`       | No           | No encontrado — el recurso no existe                                    |
| `409`       | No           | Duplicado — idempotente; usa la respuesta original                      |
| `410`       | No           | Expirado — crea una nueva iniciación                                    |
| `422`       | No           | Regla de negocio (horario, límites) — la condición debe cambiar primero |
| `429`       | Sí           | Límite de tasa — espera el valor del header `Retry-After` (segundos)    |
| `500`       | Sí           | Error interno — reintentar con backoff                                  |
| `503`       | Sí           | No disponible — reintentar con backoff                                  |

**Programación de backoff recomendada para 5xx/503:** 0s, 5s, 25s, 60s, 120s (5 intentos en total).

<Note>
  Cuando JD SPB no está disponible, la respuesta es `HTTP 503` y el campo `error.code` lleva el código del proveedor JD sin transformar (por ejemplo, `TRANSPORT` para fallas de transporte o `ACE95` para timeouts) — las fallas de la cadena JD no se envuelven en un código `BTF-`. Después de agotar los reintentos, la transferencia debe marcarse para reconciliación manual. No sigas reintentando indefinidamente — la red JD SPB tiene horarios operativos definidos.
</Note>

## Manejo de estados

***

### Máquina de estados de TED OUT

Las transferencias siguen una progresión estricta. Una vez que una transferencia sale de `CREATED` o `PENDING`, no puede cancelarse.

<Frame>
  <img src="https://mintcdn.com/lerian-49cb71fc/ZrZBZTM4DWnrahSd/images/es/d2/ted-state-machine-ted-out.svg?fit=max&auto=format&n=ZrZBZTM4DWnrahSd&q=85&s=edc09870ca40cf40602fa367498d3744" alt="Máquina de estados TED OUT" width="1116" height="552" data-path="images/es/d2/ted-state-machine-ted-out.svg" />
</Frame>

**Qué hacer en cada estado:**

| Estado       | Significado                                      | Acción recomendada                                                   |
| ------------ | ------------------------------------------------ | -------------------------------------------------------------------- |
| `CREATED`    | Confirmado por el usuario, en cola para envío    | Mostrar "Procesando" en la interfaz; hacer polling o esperar webhook |
| `PENDING`    | Enviado a JD, esperando reconocimiento           | Mostrar "Procesando"; no permitir cancelación                        |
| `PROCESSING` | JD aceptó y está enrutando la transferencia      | Mostrar "Procesando"; SLA típico menor a 10 minutos                  |
| `COMPLETED`  | Liquidado                                        | Mostrar confirmación con `confirmationNumber`                        |
| `REJECTED`   | JD rechazó (datos inválidos, violación de regla) | Mostrar error al usuario; fondos ya liberados                        |
| `FAILED`     | JD inaccesible o timeout                         | Mostrar error; fondos ya liberados; permitir reintento si se desea   |
| `CANCELLED`  | Cancelado antes del envío                        | Mostrar confirmación de cancelación                                  |

### Máquina de estados de iniciación

La entidad `PaymentInitiation` (creada por el endpoint initiate) tiene su propio ciclo de vida antes de que se cree un `Transfer`.

<Frame>
  <img src="https://mintcdn.com/lerian-49cb71fc/ZrZBZTM4DWnrahSd/images/es/d2/ted-state-machine-initiation.svg?fit=max&auto=format&n=ZrZBZTM4DWnrahSd&q=85&s=c868eb2314c927f1e457a09c8b1c3ab4" alt="Máquina de estados de iniciación" width="1102" height="410" data-path="images/es/d2/ted-state-machine-initiation.svg" />
</Frame>

### Máquina de estados de TED IN

<Frame>
  <img src="https://mintcdn.com/lerian-49cb71fc/ZrZBZTM4DWnrahSd/images/es/d2/ted-state-machine-ted-in.svg?fit=max&auto=format&n=ZrZBZTM4DWnrahSd&q=85&s=386bbde43ba3086136fe7005681ae17f" alt="Máquina de estados TED IN" width="896" height="410" data-path="images/es/d2/ted-state-machine-ted-in.svg" />
</Frame>

### Máquina de estados de P2P

P2P no tiene estado `PENDING`. La liquidación es atómica e instantánea.

<Frame>
  <img src="https://mintcdn.com/lerian-49cb71fc/ZrZBZTM4DWnrahSd/images/es/d2/ted-state-machine-ted-p2p.svg?fit=max&auto=format&n=ZrZBZTM4DWnrahSd&q=85&s=121878aa285b195a416cb7068f6b6405" alt="Máquina de estados P2P" width="805" height="461" data-path="images/es/d2/ted-state-machine-ted-p2p.svg" />
</Frame>

### Polling vs. webhooks

Prefiere los webhooks para el estado en tiempo real. Si los webhooks aún no están configurados, haz polling en `GET /v1/transfers/{transferId}` con un máximo de 10 intentos usando la misma programación de backoff que los reintentos. Después de 10 minutos sin un estado terminal (`COMPLETED`, `REJECTED`, `FAILED`, `CANCELLED`), marca la transferencia para revisión manual.

Consulta [Obtener Transferencia](/es/reference/midaz/plugins/ted/retrieve-transfer) y [Webhooks](/es/rails/ted/jd/ted-webhooks).

## Integración de webhooks

***

Para los esquemas de payload de eventos y la lista completa de eventos, consulta [Webhooks](/es/rails/ted/jd/ted-webhooks).

### Validación de firma

Cada solicitud de webhook incluye headers que tu endpoint usa para verificar la autenticidad:

* `X-Webhook-Signature` — firma HMAC-SHA256 versionada en el formato `v1,sha256=<hex>`
* `X-Webhook-Timestamp` — timestamp Unix en segundos (UTC) de cuando el plugin construyó la solicitud
* `X-Webhook-Event` — el tipo de evento (por ejemplo, `transfer.completed`); no forma parte de la firma

El plugin calcula la firma así:

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

El payload firmado es el prefijo literal `v1:`, seguido del valor del timestamp (la cadena enviada en `X-Webhook-Timestamp`), un único punto ASCII (`.`) y los **bytes crudos del cuerpo de la solicitud** — exactamente como se recibieron, antes de cualquier parseo o re-codificación JSON. Usa los bytes que llegaron por la red, no una versión re-serializada del objeto parseado.

Para validar:

1. Lee `X-Webhook-Signature` y `X-Webhook-Timestamp` de los headers de la solicitud.
2. Construye el payload firmado: `"v1:" + timestamp + "." + rawBody`.
3. Calcula `HMAC-SHA256` sobre el payload firmado usando tu `WEBHOOK_SIGNING_SECRET` y codifica el resultado en hex.
4. Antepón `v1,sha256=` y compara contra `X-Webhook-Signature` usando una función de igualdad de tiempo constante.
5. Rechaza la solicitud si el timestamp está fuera de una ventana de frescura aceptable (una tolerancia de 5 minutos es típica) para prevenir replay.

Aparte de `X-Webhook-Signature` y `X-Webhook-Timestamp`, el plugin establece únicamente `X-Webhook-Event` (el tipo de evento). No envía `X-Webhook-Event-Type`, `X-Webhook-Routing-Key` ni `X-Webhook-Delivery-Attempt`.

<AccordionGroup>
  <Accordion title="JavaScript">
    ```javascript theme={null}
    const crypto = require('crypto');
    const express = require('express');

    const TOLERANCE_SECONDS = 300; // 5 minutos

    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.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>

  <Accordion title="Python">
    ```python theme={null}
    import hmac
    import hashlib
    import time

    TOLERANCE_SECONDS = 300  # 5 minutos

    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>

  <Accordion title="Go">
    ```go theme={null}
    import (
        "crypto/hmac"
        "crypto/sha256"
        "encoding/hex"
        "strconv"
        "time"
    )

    const toleranceSeconds = 300 // 5 minutos

    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))
    }
    ```
  </Accordion>
</AccordionGroup>

### Procesamiento idempotente de webhooks

Tu endpoint puede recibir el mismo evento más de una vez (entrega al menos una vez). Usa `transferId` + `event` como clave compuesta para deduplicar.

```javascript theme={null}
const alreadyProcessed = await db.webhookEvents.exists({
  transferId: payload.transferId,
  event: payload.type,
});

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

## Patrones de manejo de errores

***

Mapea los códigos de error de la API a acciones orientadas al usuario. Consulta la [lista completa de errores](/es/reference/midaz/plugins/ted/ted-error-list) para todos los códigos.

| Escenario                                          | Mensaje para el usuario                                                                       | Acción                                                                                                                                 |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| **Fuera del horario operativo** (`BTF-0010`)       | "Transferencias disponibles lun–vie, 06:30–17:00 (Brasilia). Próxima ventana: {hora}"         | Mostrar próxima hora disponible                                                                                                        |
| **Límite diario excedido** (`BTF-0011`)            | "Límite diario de transferencias alcanzado. Intente mañana."                                  | Mostrar límite restante                                                                                                                |
| **Transferencia duplicada** (`BTF-0012`)           | "Esta transferencia ya fue enviada."                                                          | Devolver `transferId` original                                                                                                         |
| **Datos de destinatario inválidos** (`BTF-0001`)   | "Verifique los datos del destinatario e intente nuevamente."                                  | Resaltar campos inválidos                                                                                                              |
| **Iniciación expirada** (`BTF-0202`)               | "Sesión expirada. Por favor, inicie una nueva transferencia."                                 | Reiniciar el flujo de iniciación                                                                                                       |
| **JD SPB no disponible** (`TRANSPORT`, HTTP `503`) | "Servicio de transferencias temporalmente no disponible. Intente nuevamente en unos minutos." | Reintentar con backoff; detecta vía `503` + código de proveedor JD sin transformar (`TRANSPORT`, `ACE95`, …), no por un prefijo `BTF-` |
| **Midaz no disponible** (`BTF-2000`)               | "Servicio temporalmente no disponible. Intente nuevamente en unos minutos."                   | Reintentar con backoff                                                                                                                 |

Las respuestas de error siguen esta estructura:

```json theme={null}
{
  "error": {
    "code": "BTF-0010",
    "service": "plugin",
    "category": "deterministic",
    "message": "Transfers can only be initiated Monday-Friday between 06:30 and 17:00 Brasília time",
    "requestId": "6d3e2a68-1f2b-4c3d-9e4f-5a6b7c8d9e0f",
    "fields": {
      "currentTime": "2026-01-21T18:30:00-03:00",
      "nextAvailableTime": "2026-01-22T06:30:00-03:00"
    }
  }
}
```

## Lista de verificación para salida en producción

***

Antes de habilitar la integración en producción:

* [ ] `X-Idempotency` se envía en cada solicitud de initiate, process y cancel
* [ ] Lógica de reintentos implementada con backoff exponencial para errores 5xx/503
* [ ] Endpoint de webhook desplegado y devolviendo `200` en menos de 5 segundos
* [ ] Validación de firma activa en el endpoint de webhook
* [ ] Deduplicación de eventos de webhook implementada usando `transferId + event`
* [ ] Horario de funcionamiento validado en el lado del cliente antes de llamar a initiate (reduce los 422 innecesarios)
* [ ] Tanto `transferId` como `confirmationNumber` almacenados para reconciliación
* [ ] Estados terminales (`COMPLETED`, `REJECTED`, `FAILED`, `CANCELLED`) gestionados en la interfaz
* [ ] Expiración de iniciación (24h) manejada — se solicita al usuario que reinicie si la ventana expira
* [ ] Readiness del servicio monitoreado en tu sistema de alertas para despliegues BYOC
* [ ] Redis está disponible y monitoreado — el servicio no aceptará solicitudes si Redis es inalcanzable
* [ ] `PLUGIN_AUTH_ENABLED=true` configurado en producción, con un `PLUGIN_AUTH_ADDRESS` válido (HTTPS)
