Inyo

Webhooks

Los webhooks le permiten recibir notificaciones en tiempo real cuando ocurren eventos importantes en la plataforma de Inyo — como un cambio de estado de una transacción, una decisión de cumplimiento o la finalización de la verificación de un documento. En lugar de consultar la API por polling, usted registra una URL de callback e Inyo le envía los eventos.


Eventos soportados

EventoDescripción
TransactionStatusChangedLa transacción avanzó por la máquina de estados interna de Inyo (p. ej., PaymentAuthorizedProcessingPayout). El evento más frecuente — una transacción típica produce entre 8 y 10 de estos a lo largo de su ciclo de vida.
TransactionComplianceStatusChangedEventEl estado de cumplimiento de la transacción cambió (p. ej., PendingApproved). Se dispara solo cuando el valor de cumplimiento realmente difiere de su valor anterior.
TransactionPayoutStatusChangedEl gateway de pagos reportó un cambio de estado en el tramo del payout (Inyo → destinatario). Señal del gateway casi en tiempo real, más rápida que el evento de la máquina de estados.
DocumentUpdatedEventsSe cargó un documento, o se completó su verificación (aprobado/rechazado).
AgentUpdatedEventsSe creó un registro de agente.

Sensibilidad a mayúsculas del nombre del evento: la coincidencia de suscripciones no distingue mayúsculas — TransactionStatusChanged, transactionstatuschanged y TRANSACTIONSTATUSCHANGED se suscriben todos al mismo evento. El campo event en el payload entregado repite la escritura exacta que usted registró, así que compárelo sin distinguir mayúsculas en su manejador.

El nombre del evento de cumplimiento lleva un sufijo Event al final (TransactionComplianceStatusChangedEvent) — histórico, mantenido por compatibilidad hacia atrás.


Registrar un webhook

Endpoint: POST /organizations/{tenant}/webhooks
Autenticación: Nivel de tenant (x-api-key)

curl --request POST \
  --url https://{FQDN}/organizations/$TENANT/webhooks \
  --header 'Content-Type: application/json' \
  --header "x-api-key: $API_KEY" \
  --data '{
  "url": "https://your-server.com/api/webhooks/inyo",
  "events": [
    "TransactionStatusChanged",
    "TransactionComplianceStatusChangedEvent",
    "TransactionPayoutStatusChanged",
    "DocumentUpdatedEvents",
    "AgentUpdatedEvents"
  ]
}'
CampoTipoRequeridoDescripción
urlstringEndpoint HTTPS que recibirá las entregas (máx. 2048 caracteres)
eventsstring[]Al menos un nombre de evento (coincidencia sin distinguir mayúsculas)

Reglas de suscripción:

  • Solo los eventos que usted liste se disparan en ese endpoint.
  • Se admiten múltiples endpoints por tenant — registre una vez por URL, cada uno con su propia lista de eventos.
  • No existe un endpoint de actualización. Para cambiar la lista de eventos de una suscripción existente, elimínela y vuelva a crearla.

Las entregas no van firmadas por defecto. Para que cada entrega lleve un encabezado X-Inyo-Signature HMAC-SHA256, genere un secreto de firma para la suscripción — vea la siguiente sección.


Generar o rotar un secreto de firma

Endpoint: POST /organizations/{tenant}/webhooks/{webhookId}/secret
Autenticación: Nivel de agente (x-api-key + x-agent-id + x-agent-api-key)

Genera un secreto de firma en la primera llamada, o lo rota (reemplazando el actual de forma atómica) en llamadas posteriores. Una vez configurado un secreto, cada entrega a esa suscripción lleva el encabezado X-Inyo-Signature.

A diferencia de los demás endpoints de webhooks, las operaciones del secreto requieren credenciales de agente además de la clave de tenant. Rotar un secreto de firma es sensible en materia de seguridad (otorga la capacidad de falsificar o verificar entregas firmadas), por lo que se requiere una identidad de agente para el rastro de auditoría — el mismo estándar que los endpoints transaccionales.

curl --request POST \
  --url https://{FQDN}/organizations/$TENANT/webhooks/$WEBHOOK_ID/secret \
  --header "x-api-key: $API_KEY" \
  --header "x-agent-id: $AGENT_ID" \
  --header "x-agent-api-key: $AGENT_KEY"

Respuesta (200):

{
  "signingSecret": "nZl3F0p9pKcW1sT7Yb2eXo4qUvJ8hRmA6dCgE5wLiSk=",
  "secretFingerprint": "abcd1234",
  "signatureVersion": "v1",
  "secretRotatedAt": "2026-08-04T15:30:00+00:00"
}
CampoDescripción
signingSecretEl secreto en bruto (44 caracteres en base64 de 32 bytes aleatorios). Se muestra solo en esta respuesta — guárdelo de inmediato en su gestor de secretos. No existe un endpoint GET; el valor no puede recuperarse nunca más.
secretFingerprintLos primeros 8 caracteres hexadecimales de sha256(secret) — seguro de mostrar; úselo para confirmar qué secreto está activo
signatureVersionVersión del esquema de firma, actualmente v1
secretRotatedAtCuándo se generó/rotó el secreto

La rotación reemplaza el secreto de inmediato — no hay un período de gracia con doble secreto. Las entregas firmadas con el secreto anterior se detienen en el momento en que usted rota; vea Rotación del secreto para un procedimiento sin tiempo de inactividad.

Deshabilitar la firma

Endpoint: DELETE /organizations/{tenant}/webhooks/{webhookId}/secret
Autenticación: Nivel de agente (x-api-key + x-agent-id + x-agent-api-key)

curl --request DELETE \
  --url https://{FQDN}/organizations/$TENANT/webhooks/$WEBHOOK_ID/secret \
  --header "x-api-key: $API_KEY" \
  --header "x-agent-id: $AGENT_ID" \
  --header "x-agent-api-key: $AGENT_KEY"

Devuelve 204. Las entregas se reanudan sin el encabezado X-Inyo-Signature — deshabilite primero la verificación de su lado, o su endpoint comenzará a rechazarlas.


Listar los webhooks registrados

Endpoint: GET /organizations/{tenant}/webhooks
Autenticación: Nivel de tenant (x-api-key)

curl --request GET \
  --url https://{FQDN}/organizations/$TENANT/webhooks \
  --header "x-api-key: $API_KEY"

Respuesta:

{
  "webhooks": [
    {
      "id": "6f0e3a3a-6f27-4a2e-9c1f-1f2f3a4b5c6d",
      "url": "https://your-server.com/api/webhooks/inyo",
      "events": ["TransactionStatusChanged"],
      "isBatch": false,
      "createdAt": "2026-08-01T12:00:00+00:00",
      "signatureVersion": "v1",
      "secretFingerprint": "abcd1234",
      "secretRotatedAt": "2026-08-04T15:30:00+00:00"
    }
  ]
}

El secreto de firma en bruto nunca se incluye — secretFingerprint (null cuando no hay secreto configurado) le indica si la firma está activa y qué secreto está en uso.


Eliminar un webhook

Endpoint: DELETE /organizations/{tenant}/webhooks/{webhookId}
Autenticación: Nivel de tenant (x-api-key)

curl --request DELETE \
  --url https://{FQDN}/organizations/$TENANT/webhooks/$WEBHOOK_ID \
  --header "x-api-key: $API_KEY"

Payloads de eventos

Todas las entregas son solicitudes POST a su URL registrada con Content-Type: application/json.

1. TransactionStatusChanged

Se dispara cuando la máquina de estados interna de Inyo hace transicionar la transacción.

{
  "event": "TransactionStatusChanged",
  "transactionId": "0a3ca5d5-927d-4784-9dbe-a10087a746cc",
  "externalTransactionId": "de70d60f-11e7-431c-bd5d-4ae4f2f808e4",
  "tenantId": "your-tenant-slug",
  "oldStatus": "Paid",
  "newStatus": "Completed",
  "newStatusMessages": ["Transaction completed"]
}
CampoTipoNotas
eventstringRepite la escritura que usted registró
transactionIduuidUUID de la transacción en Inyo
externalTransactionIdstring | nullSu externalId de la creación de la transacción; null si no envió uno
tenantIdstringEl slug de su tenant
oldStatusstring | nullEstado anterior; null en la primera transición
newStatusstringNuevo estado — vea los valores a continuación
newStatusMessagesstring[]Contexto legible para humanos, si lo hay

Valores de estado:

Created, PaymentProcessing, WaitingChallenge3ds, PaymentAuthorized, PaymentDeclined, PaymentCaptured, WaitingSettlement, PaymentSettled, ProcessingPayout, PayoutAccepted, PayoutHold, PayoutReleased, PayoutRejected, ManualReview, ReviewApproved, ReviewRejected, WaitingPayout, Paid, Completed, Cancelled, CancelRequested, Refunded, Voided, Error, PendingReversalApproval, BlockedPendingReview.

Secuencia típica del camino feliz (tarjeta, sin 3DS):

Created → PaymentProcessing → PaymentAuthorized → ProcessingPayout
       → PayoutAccepted → ReviewApproved → PaymentCaptured
       → WaitingPayout → Paid → Completed

Vea el ciclo de vida de la transacción para los flujos de estado completos, incluyendo 3DS y ACH.

2. TransactionComplianceStatusChangedEvent

Se dispara solo cuando el estado de cumplimiento realmente cambia. Las transiciones de estado que no mueven el cumplimiento no emiten este evento.

{
  "event": "TransactionComplianceStatusChangedEvent",
  "transactionId": "0a3ca5d5-927d-4784-9dbe-a10087a746cc",
  "externalTransactionId": "de70d60f-11e7-431c-bd5d-4ae4f2f808e4",
  "tenantId": "your-tenant-slug",
  "oldComplianceStatus": "Pending",
  "newComplianceStatus": "Approved",
  "newStatusMessages": []
}
CampoTipoNotas
oldComplianceStatusstring | nullCon mayúscula inicial; null en la primera transición
newComplianceStatusstringCon mayúscula inicial — vea los valores a continuación

Valores del estado de cumplimiento: Pending, Approved, Rejected, Cancelled, Refunded, Failed.

La mayoría de las transacciones producen un único evento de cumplimiento: Pending → Approved (o una transición terminal como Pending → Cancelled). Tenga en cuenta que una retención de payout de GMT no cambia el estado de cumplimiento — las retenciones aparecen como PayoutHold en TransactionStatusChanged.

3. TransactionPayoutStatusChanged

Se dispara cuando el gateway de pagos reporta un cambio de estado en el tramo del payout de una transacción (Inyo → banco/billetera del destinatario). Esta es una señal del gateway casi en tiempo real, distinta de TransactionStatusChanged — este último refleja la máquina de estados interna de Inyo, que en el lado del payout se impulsa principalmente mediante un poller periódico. Suscríbase si desea una visibilidad más rápida del payout.

{
  "event": "TransactionPayoutStatusChanged",
  "transactionId": "0a3ca5d5-927d-4784-9dbe-a10087a746cc",
  "externalTransactionId": "de70d60f-11e7-431c-bd5d-4ae4f2f808e4",
  "tenantId": "your-tenant-slug",
  "oldPayoutStatus": "PENDING",
  "newPayoutStatus": "AUTHORIZED",
  "gatewayPaymentId": "d5af3652-909e-44b6-9f89-f14d055baf26",
  "gmtReceipt": "GMT000641475307",
  "amount": {
    "total": 48.74999979043565,
    "currency": "USD"
  },
  "newStatusMessages": ["Payment: CONFIRM_PENDING"]
}
CampoTipoNotas
oldPayoutStatusstring | nullEstado bruto del gateway
newPayoutStatusstringEstado bruto del gateway — vea a continuación
gatewayPaymentIdstring | nullLa referencia de pago propia del gateway
gmtReceiptstring | nullSe completa cuando el payout se enrutó a través de la red MSB (la mayoría de los casos)
amountobject | null{ "total": number, "currency": string }. total es un float en bruto — no está redondeado a las unidades menores de la moneda. Redondee de su lado.

newPayoutStatus es la cadena en bruto que envió el gateway — sin mapeo a enums del lado de Inyo. Valores comunes: AUTHORIZED, PENDING, CAPTURED, DECLINED, VOIDED, REFUNDED, ERROR. Pueden aparecer nuevos códigos del gateway en cualquier momento — trate las cadenas desconocidas como "no se requiere acción" en lugar de generar un error.

4. DocumentUpdatedEvents

Se dispara cuando se carga un documento, y nuevamente cuando la verificación se completa (OCR o revisión manual).

Suscríbase como DocumentUpdatedEvents (recomendado). Las suscripciones existentes registradas con la escritura heredada en minúsculas documentUpdatedEvents siguen funcionando y siguen recibiendo esa escritura en el payload.

{
  "event": "DocumentUpdatedEvents",
  "id": "4ec66735-216b-4ab4-b1d7-00558baa6d85",
  "entityId": "6588c7e7-3b1a-42ff-94ab-a834eda66640",
  "entityType": "Participant",
  "documentType": "DRIVER_LICENSE",
  "verificationStatus": "VERIFIED",
  "tenantId": "your-tenant-slug",
  "version": 1
}
CampoTipoNotas
iduuidEl UUID de la carga del documento
entityIduuidEl participante al que pertenece el documento
entityTypestringParticipant
documentTypestringPASSPORT, DRIVER_LICENSE, SSN, PROOF_OF_FUNDS, etc. — la misma taxonomía que los endpoints de carga
verificationStatusstringPENDING (cargado, a la espera de verificación), VERIFIED o REJECTED. Compare sin distinguir mayúsculas — el evento inicial de carga puede entregar Pending.
versionnumberVersión del esquema — siempre 1 hoy

Ciclo de vida típico: carga → webhook con PENDING → verificación asíncrona (minutos) → webhook con VERIFIED o REJECTED. Si el OCR con IA no está habilitado para su tenant, solo se dispara el webhook pendiente y la verificación la maneja manualmente el equipo de cumplimiento.

5. AgentUpdatedEvents

Se dispara cuando se crea un registro de agente mediante POST /organizations/{tenant}/agents.

Suscríbase como AgentUpdatedEvents (recomendado). Las suscripciones existentes registradas con la escritura heredada en minúsculas agentUpdatedEvents siguen funcionando y siguen recibiendo esa escritura en el payload.

{
  "event": "AgentUpdatedEvents",
  "id": "88fa9606-8345-454a-9669-19f13ccdae13",
  "before": null,
  "after": {
    "id": "88fa9606-8345-454a-9669-19f13ccdae13",
    "email": "[email protected]",
    "businessName": "Your Agent Business Name",
    "externalId": null,
    "status": "PENDING_APPROVAL",
    "createdAt": "2026-06-02T18:20:10+00:00"
  },
  "tenantId": "your-tenant-slug",
  "version": 1
}

before es null en la creación (el único camino que actualmente emite este evento).


Verificación de firmas (HMAC-SHA256)

Cuando hay un secreto de firma configurado para su suscripción, cada entrega lleva un encabezado X-Inyo-Signature. Verificarlo le permite a su endpoint rechazar solicitudes falsificadas y repeticiones (replays).

Formato del encabezado

X-Inyo-Signature: t=1704829200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
ComponenteSignificado
t=<seconds>Marca de tiempo Unix (segundos) en la que se calculó la firma — úsela para rechazar replays fuera de una ventana de tolerancia
v1=<hex>HMAC-SHA256 codificado en hexadecimal. v1 es la versión actual del esquema; revisiones futuras usarán v2, v3, … para que usted pueda migrar sin un corte abrupto

Si su suscripción no tiene secreto de firma, el encabezado no se envía y la entrega va sin firmar.

La cadena de bytes firmada

La firma es HMAC-SHA256(secret, signed_string), donde signed_string es la concatenación ASCII:

<timestamp> + "." + <raw request body>
  • <timestamp> — los dígitos ASCII exactos del componente t=. Use la subcadena tal cual; no la re-parsee ni la re-formatee.
  • "." — un único punto ASCII, sin espacios.
  • <raw request body> — los bytes exactos del cuerpo de la solicitud HTTP, antes de que cualquier framework los haya parseado o re-serializado.

Firme sobre bytes en bruto — este es el paso en el que más se equivocan los integradores. No haga JSON.parse() y re-stringify() del cuerpo: eso reordena las claves y re-escapa caracteres, rompiendo la firma. Capture el cuerpo en bruto antes de que corra el middleware JSON de su framework — Express: express.raw({ type: 'application/json' }); Django: request.body; Rails: request.raw_post; Laravel: $request->getContent().

Inyo serializa las entregas de forma compacta (sin pretty-printing), con barras diagonales y caracteres no ASCII sin escapar, y sin salto de línea final — pero nada de eso importa si usted verifica contra los bytes en bruto tal como se recibieron.

Pasos de verificación

  1. Lea el encabezado X-Inyo-Signature. Rechace si está ausente.
  2. Extraiga t y el componente v1. Rechace si alguno falta o está mal formado.
  3. Rechace si |now − t| > 300 segundos (ventana de replay de 5 minutos; ajústela según su tolerancia de desfase de reloj).
  4. Construya signed_string = t + "." + raw_body.
  5. Calcule expected = HMAC-SHA256(secret, signed_string), codificado en hexadecimal en minúsculas.
  6. Compare expected con el valor de v1 en tiempo constante (crypto.timingSafeEqual, hmac.compare_digest, hash_equals) — nunca ==.
  7. Solo después de que todas las verificaciones pasen, parsee el cuerpo como JSON y actúe sobre el payload.

Node.js (Express)

const crypto = require('crypto');
const express = require('express');
const app = express();

// IMPORTANTE: cuerpo en bruto, no parseado como JSON. Regístrelo ANTES de cualquier middleware express.json().
app.post('/webhooks/inyo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const secret = process.env.INYO_WEBHOOK_SECRET;
    const header = req.get('X-Inyo-Signature') || '';
    const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
    const { t, v1: sig } = parts;

    if (!t || !sig) return res.status(400).send('bad signature header');
    if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).send('stale');

    const signedString = t + '.' + req.body.toString('utf8');
    const expected = crypto.createHmac('sha256', secret).update(signedString).digest('hex');

    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(sig, 'hex');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(400).send('signature mismatch');
    }

    // Confirme de inmediato — tiene 10 segundos antes de que la entrega expire
    res.status(200).send('OK');

    const { event, transactionId, oldStatus, newStatus } = JSON.parse(req.body.toString('utf8'));
    if (event.toLowerCase() === 'transactionstatuschanged') {
      // …actualice sus registros…
    }
  });

app.listen(3001);

Python (Flask)

import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["INYO_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/inyo")
def inyo_webhook():
    header = request.headers.get("X-Inyo-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, sig = parts.get("t"), parts.get("v1")
    if not t or not sig:
        abort(400)
    if abs(int(time.time()) - int(t)) > 300:
        abort(400)

    raw = request.get_data()  # bytes, no request.json
    signed = t.encode() + b"." + raw
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(400)

    payload = request.get_json()
    # …procese el payload…
    return "", 200

Rotación del secreto

Hay un secreto activo por suscripción; rotarlo lo reemplaza de inmediato — no hay período de gracia con doble secreto. Para rotar sin tiempo de inactividad:

  1. Acepte temporalmente las entregas que fallen la verificación (regístrelas en logs en lugar de rechazarlas).
  2. Llame a POST /organizations/{tenant}/webhooks/{webhookId}/secret y guarde el nuevo signingSecret de la respuesta.
  3. Vuelva a habilitar la verificación estricta con el nuevo secreto.

Confirme qué secreto está activo en cualquier momento comparando el secretFingerprint de GET /webhooks con los primeros 8 caracteres hexadecimales de sha256(your_stored_secret).

Modos de falla comunes

  • Cuerpo re-serializado antes de verificar — el middleware parseó el JSON y su manejador lo volvió a convertir en cadena. Capture los bytes en bruto antes de cualquier parser.
  • Deriva de codificación — leer el cuerpo como latin-1 y re-codificarlo como UTF-8 cambia los bytes. Mantenga los bytes tal cual hasta después de la verificación.
  • Comparación en tiempo no constante== abre un canal lateral de temporización; use la comparación en tiempo constante de su lenguaje.
  • Deriva de reloj — un reloj de host desfasado más de 5 minutos rechaza todo. Mantenga NTP habilitado.

Entrega y confiabilidad

  • Reintentos: las respuestas distintas de 2xx (y los timeouts) se reintentan hasta 3 intentos en total, espaciados aproximadamente 1 minuto entre sí.
  • Timeout: su endpoint tiene 10 segundos para responder. Devuelva un 2xx de inmediato y procese de forma asíncrona — los manejadores de larga duración son interrumpidos y reintentados, produciendo duplicados.
  • Firmas: las entregas llevan un encabezado X-Inyo-Signature HMAC-SHA256 cuando hay un secreto de firma configurado para su suscripción (vea Verificación de firmas); de lo contrario, van sin firmar. Especialmente para suscripciones sin firma, trate el payload como una pista: obtenga el estado autoritativo mediante GET /fx/transactions/{id} antes de tomar acciones críticas para el negocio.
  • Duplicados: los reintentos pueden entregar el mismo evento más de una vez (no hay encabezado de id de entrega). Base su manejador en (transactionId, newStatus) (o una tupla equivalente) y trate las repeticiones como no-ops.
  • Orden: no está garantizado. Dos eventos de la misma transacción pueden llegar fuera de orden. Si la secuencia importa, compare el oldStatus entrante con el estado que usted tiene almacenado — si no coinciden, ignore la entrega y deje que una entrega posterior (o un poll) reconcilie.

Todos los endpoints

OperaciónMétodoEndpoint
Registrar webhookPOST/organizations/{tenant}/webhooks
Listar webhooksGET/organizations/{tenant}/webhooks
Eliminar webhookDELETE/organizations/{tenant}/webhooks/{webhookId}
Generar / rotar secreto de firmaPOST/organizations/{tenant}/webhooks/{webhookId}/secret
Deshabilitar firmaDELETE/organizations/{tenant}/webhooks/{webhookId}/secret