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
| Evento | Descripción |
|---|---|
TransactionStatusChanged | La transacción avanzó por la máquina de estados interna de Inyo (p. ej., PaymentAuthorized → ProcessingPayout). 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. |
TransactionComplianceStatusChangedEvent | El estado de cumplimiento de la transacción cambió (p. ej., Pending → Approved). Se dispara solo cuando el valor de cumplimiento realmente difiere de su valor anterior. |
TransactionPayoutStatusChanged | El 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. |
DocumentUpdatedEvents | Se cargó un documento, o se completó su verificación (aprobado/rechazado). |
AgentUpdatedEvents | Se creó un registro de agente. |
Sensibilidad a mayúsculas del nombre del evento: la coincidencia de suscripciones no distingue mayúsculas —
TransactionStatusChanged,transactionstatuschangedyTRANSACTIONSTATUSCHANGEDse suscriben todos al mismo evento. El campoeventen 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
Evental 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"
]
}'
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
url | string | Sí | Endpoint HTTPS que recibirá las entregas (máx. 2048 caracteres) |
events | string[] | Sí | 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"
}
| Campo | Descripción |
|---|---|
signingSecret | El 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. |
secretFingerprint | Los primeros 8 caracteres hexadecimales de sha256(secret) — seguro de mostrar; úselo para confirmar qué secreto está activo |
signatureVersion | Versión del esquema de firma, actualmente v1 |
secretRotatedAt | Cuá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"]
}
| Campo | Tipo | Notas |
|---|---|---|
event | string | Repite la escritura que usted registró |
transactionId | uuid | UUID de la transacción en Inyo |
externalTransactionId | string | null | Su externalId de la creación de la transacción; null si no envió uno |
tenantId | string | El slug de su tenant |
oldStatus | string | null | Estado anterior; null en la primera transición |
newStatus | string | Nuevo estado — vea los valores a continuación |
newStatusMessages | string[] | 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": []
}
| Campo | Tipo | Notas |
|---|---|---|
oldComplianceStatus | string | null | Con mayúscula inicial; null en la primera transición |
newComplianceStatus | string | Con 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"]
}
| Campo | Tipo | Notas |
|---|---|---|
oldPayoutStatus | string | null | Estado bruto del gateway |
newPayoutStatus | string | Estado bruto del gateway — vea a continuación |
gatewayPaymentId | string | null | La referencia de pago propia del gateway |
gmtReceipt | string | null | Se completa cuando el payout se enrutó a través de la red MSB (la mayoría de los casos) |
amount | object | 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úsculasdocumentUpdatedEventssiguen 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
}
| Campo | Tipo | Notas |
|---|---|---|
id | uuid | El UUID de la carga del documento |
entityId | uuid | El participante al que pertenece el documento |
entityType | string | Participant |
documentType | string | PASSPORT, DRIVER_LICENSE, SSN, PROOF_OF_FUNDS, etc. — la misma taxonomía que los endpoints de carga |
verificationStatus | string | PENDING (cargado, a la espera de verificación), VERIFIED o REJECTED. Compare sin distinguir mayúsculas — el evento inicial de carga puede entregar Pending. |
version | number | Versió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úsculasagentUpdatedEventssiguen 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
| Componente | Significado |
|---|---|
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 componentet=. 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
- Lea el encabezado
X-Inyo-Signature. Rechace si está ausente. - Extraiga
ty el componentev1. Rechace si alguno falta o está mal formado. - Rechace si
|now − t| > 300segundos (ventana de replay de 5 minutos; ajústela según su tolerancia de desfase de reloj). - Construya
signed_string = t + "." + raw_body. - Calcule
expected = HMAC-SHA256(secret, signed_string), codificado en hexadecimal en minúsculas. - Compare
expectedcon el valor dev1en tiempo constante (crypto.timingSafeEqual,hmac.compare_digest,hash_equals) — nunca==. - 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:
- Acepte temporalmente las entregas que fallen la verificación (regístrelas en logs en lugar de rechazarlas).
- Llame a
POST /organizations/{tenant}/webhooks/{webhookId}/secrety guarde el nuevosigningSecretde la respuesta. - 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
2xxde 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-SignatureHMAC-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 medianteGET /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
oldStatusentrante 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ón | Método | Endpoint |
|---|---|---|
| Registrar webhook | POST | /organizations/{tenant}/webhooks |
| Listar webhooks | GET | /organizations/{tenant}/webhooks |
| Eliminar webhook | DELETE | /organizations/{tenant}/webhooks/{webhookId} |
| Generar / rotar secreto de firma | POST | /organizations/{tenant}/webhooks/{webhookId}/secret |
| Deshabilitar firma | DELETE | /organizations/{tenant}/webhooks/{webhookId}/secret |
