Saltar al contenido principal
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.

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
POST /v1/transfers/initiate
X-Organization-Id: 019c9ac2-3f5d-7df9-9215-bdccc1451def
X-Idempotency: 7f3d9a1b-4e2c-4f8a-b3d1-9e6f2a4c8b7e
No reutilices claves de idempotencia en diferentes operaciones. Una clave usada para iniciar una transferencia no debe reutilizarse para procesarla o cancelarla.

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
400NoError de validación — corrige la solicitud antes de reintentar
404NoNo encontrado — el recurso no existe
409NoDuplicado — idempotente; usa la respuesta original
410NoExpirado — crea una nueva iniciación
422NoRegla de negocio (horario, límites) — la condición debe cambiar primero
429Límite de tasa — espera el valor del header Retry-After (segundos)
500Error interno — reintentar con backoff
503No disponible — reintentar con backoff
Programación de backoff recomendada para 5xx/503: 0s, 5s, 25s, 60s, 120s (5 intentos en total).
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.

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.
Máquina de estados TED OUT
Qué hacer en cada estado:
EstadoSignificadoAcción recomendada
CREATEDConfirmado por el usuario, en cola para envíoMostrar “Procesando” en la interfaz; hacer polling o esperar webhook
PENDINGEnviado a JD, esperando reconocimientoMostrar “Procesando”; no permitir cancelación
PROCESSINGJD aceptó y está enrutando la transferenciaMostrar “Procesando”; SLA típico menor a 10 minutos
COMPLETEDLiquidadoMostrar confirmación con confirmationNumber
REJECTEDJD rechazó (datos inválidos, violación de regla)Mostrar error al usuario; fondos ya liberados
FAILEDJD inaccesible o timeoutMostrar error; fondos ya liberados; permitir reintento si se desea
CANCELLEDCancelado antes del envíoMostrar 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.
Máquina de estados de iniciación

Máquina de estados de TED IN

Máquina de estados TED IN

Máquina de estados de P2P

P2P no tiene estado PENDING. La liquidación es atómica e instantánea.
Máquina de estados P2P

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

Integración de webhooks


Para los esquemas de payload de eventos y la lista completa de eventos, consulta 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.
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');
  }
);
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)
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))
}

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.
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 para todos los códigos.
EscenarioMensaje para el usuarioAcción
Fuera del horario operativo (BTF-0010)“Transferencias disponibles lun–vie, 06:30–17:00 (Brasilia). Próxima ventana: 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:
{
  "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)