> ## 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
* El plugin asigna cada clave a la organización efectiva. La misma clave de dos organizaciones cuenta como dos solicitudes distintas.
* El plugin devuelve una respuesta en caché durante la ventana de idempotencia configurada (`IDEMPOTENCY_RETRY_WINDOW_SEC`, por defecto 300 segundos)
* Una respuesta reemitida es byte-idéntica a la original: mismo código de estado, mismo body. La respuesta no tiene un header para marcar un replay, así que diseña tu cliente para que sea 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. No reutilices una clave de initiate para procesar o cancelar la misma transferencia.
</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

El plugin almacena la huella en Redis durante 5 minutos. El valor por defecto es 300 segundos. Los operadores lo ajustan por tenant a través del ajuste de systemplane `idempotency.duplicate_guard_ttl_seconds`. La organización no forma parte de la huella. El aislamiento de tenant proviene del prefijo de clave de Redis. El plugin rechaza la solicitud con `409 BTF-0012` si el cliente ya envió una transferencia coincidente dentro de la ventana.

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

## Estrategia de reintentos

***

Usa backoff exponencial para errores transitorios. No reintentes todos los errores.

| 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`. El campo `error.code` lleva entonces el código del proveedor JD sin transformar — por ejemplo, `TRANSPORT` para fallas de transporte o `ACE95` para timeouts. El plugin no envuelve las fallas de la cadena JD en un código `BTF-`. Marca la transferencia para reconciliación manual después de que se agoten los reintentos. No reintentes sin límite. 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. No puedes cancelar una transferencia después de que sale de `CREATED` o `PENDING`.

<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

El endpoint initiate crea una entidad `PaymentInitiation`. Esta entidad tiene su propio ciclo de vida antes de que el plugin 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 aún no configuraste los webhooks, haz polling en `GET /v1/transfers/{transferId}`. Usa un máximo de 10 intentos con la misma programación de backoff que los reintentos. Marca la transferencia para revisión manual después de 10 minutos sin un estado terminal (`COMPLETED`, `REJECTED`, `FAILED`, `CANCELLED`).

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`). Este header 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 tiene cuatro partes en orden: el prefijo `v1:`, el valor del timestamp de `X-Webhook-Timestamp`, un único punto ASCII (`.`) y luego los **bytes crudos del cuerpo de la solicitud**. Usa los bytes del cuerpo exactamente como llegan por la red. No los parsees ni los recodifiques primero.

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 con tu `WEBHOOK_SIGNING_SECRET`, luego codifica el resultado en hex.
4. Antepón `v1,sha256=`, luego compara contra `X-Webhook-Signature` con 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:

* [ ] Envía `X-Idempotency` 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 — solicita al usuario que reinicie cuando la ventana expira
* [ ] Readiness del servicio monitoreado en tu sistema de alertas para despliegues BYOC
* [ ] Redis accesible y monitoreado — el servicio rechaza solicitudes cuando Redis está caído
* [ ] `PLUGIN_AUTH_ENABLED=true` configurado en producción, con un `PLUGIN_AUTH_ADDRESS` válido (HTTPS)
