Skip to main content
Los webhooks te avisan en tiempo real, en tu propio servidor, cada vez que un cobro cambia de estado — sin que tengas que consultar la API en bucle. 1to1 envía un POST firmado a la URL que registres, con el detalle del pago.
Estos webhooks son salientes (1to1 → tu servidor). No los llamas tú: los recibes. Cada entrega va firmada (estándar Standard Webhooks) para que verifiques que vino de nosotros y no fue alterada.

Configurar un endpoint

Desde el dashboard, en Configuración → API y Conexiones, agrega la URL de tu sistema y elige los eventos que quieres recibir. Al crearlo obtienes un signing secret (whsec_…) que se muestra una sola vez — guárdalo como un secreto: es la llave con la que verificas cada webhook. Puedes registrar hasta 10 endpoints por negocio.
La URL debe ser https:// y pública. Guarda el whsec_… en un lugar seguro del servidor (variable de entorno, secret manager) — nunca en código cliente ni en el repositorio. Si lo pierdes, rota el secret desde el dashboard.

Catálogo de eventos

payment.failed cubre solo los pagos manuales marcados como fallidos por tu equipo. Los fallos de pagos online (tarjeta declinada, sesión expirada) no emiten webhook.

El payload

Cada POST lleva un cuerpo JSON con esta forma. El ejemplo es un pago manual acreditado:
event_at es el momento en que ocurrió el evento del pago (para payment.failed es la hora de la falla). Es distinto del header webhook-timestamp, que es el instante de envío usado para la firma anti-replay.
Algunos campos son exclusivos de pagos manuales (provider: "manual"): bank_account (la etiqueta de la cuenta a la que entró el dinero, p. ej. "BANORTE0876"), bank_datetime (fecha/hora en banca, opcional) y files (comprobantes subidos). En pagos online quedan null / []. En pagos manuales, transaction_id es el ID de banca capturado por el operador al acreditar (null si no se capturó); en pagos online es el id del proveedor. En todo pago online acreditado receipt_url trae siempre una URL de nuestro dominio (https://…/pay/receipt/<uuid>), nunca el recibo hospedado del proveedor; en pagos manuales es null. Esa página no requiere autenticación y no tiene vencimiento por tiempo (a diferencia de los links del proveedor); deja de servir si el pago se borra o deja de estar acreditado. El PDF del recibo se descarga agregando el sufijo /pdf a esa misma URL.
El payload crece con el tiempo: podemos agregar campos nuevos al webhook sin aviso previo, y eso no cuenta como cambio incompatible. Los que ya están documentados aquí no los quitamos ni los renombramos sin anunciarlo antes, y un cambio incompatible llega como un tipo de evento nuevo, nunca alterando uno existente.Por eso tu parser debe ignorar lo que no reconozca —tanto claves como tipos de evento— en vez de fallar: no configures additionalProperties: false en tu validación de schema, ni uses structs que rechacen campos desconocidos. Una entrega que tu endpoint rechaza se reintenta durante ~3 días y puede terminar deshabilitando la suscripción.Esto aplica al shape del payload. Los límites de tamaño y el contenido de los campos de texto libre pueden cambiar: si dimensionaste tu almacenamiento por los topes de arriba, revisa esta guía antes de asumir que siguen siendo los mismos.

description y custom_fields: el contexto que escribe tu equipo

Dos campos que describen de qué se trata el pago, y que salen de lo que tu propio negocio captura:
  • description es la nota del pago. La escribe quien lo registra desde el panel, o la redacta tu AI employee si le configuraste una guía para eso. Es prosa libre: null cuando nadie escribió nada.
  • custom_fields son los campos personalizados que definiste en tu cuenta —«Tipo de pago», «Pedido», los que necesites— con el valor que se capturó en ese pago. Mientras el campo esté en despliegue por etapas la clave puede no venir; cuando viene es siempre un objeto: {} si no se capturó ninguno.
Las claves de custom_fields son los nombres que tú les pusiste, tal cual, así que si renombras un campo en tu cuenta la clave cambia en los pagos siguientes. Si vas a mapearlos a tu sistema, hazlo por el nombre que uses hoy y revisa ese mapeo cuando renombres. Cuando las dos claves vienen, hoy solo las pobla el carril de pagos manuales: en los pagos con tarjeta o link llegan null y {}.
Trátalos como texto de un humano, no confiable para lógica: escápalos antes de renderizarlos en HTML y no derives decisiones automáticas de la description ni de los valores de custom_fields. Pueden traer saltos de línea, acentos y emoji. La description está acotada a 2000 caracteres, cada valor de custom_fields a 500, el nombre de cada campo a 100 y hasta 50 campos por pago, pero dimensiona tu almacenamiento por esos límites, no por lo que veas en las primeras entregas.
Disponibilidad: estos dos campos se están habilitando por etapas a nivel de la plataforma, no cuenta por cuenta. Si todavía no los ves en tus entregas es que todavía no están publicados — escríbenos y te avisamos cuando lo estén.

Si recibes eventos por dos vías, deduplica por payment.uuid

Además de estos webhooks, un AI employee de tipo JSON puede despachar el pago a una URL tuya como parte de lo que extrae de la conversación. Si usas las dos vías, el mismo pago te va a llegar dos veces, con el mismo objeto payment en ambas. Deduplica por data.payment.uuid, que es estable entre las dos. No uses el folio: los pagos con tarjeta y link no siempre lo traen. Si además usas un AI employee de tipo JSON que adjunte el pago, lo que recibes dentro de su campo es este mismo objeto data completopayment, contact_info y conversation_info—, así que el código que ya escribiste para procesarlo te sirve igual. Lo único que no lleva es el envelope (id, type, event_at), porque ése describe esta entrega en particular.
Ahí conversation_info.is_tester sí puede llegar en true. Estos webhooks nunca te mandan pagos de prueba, pero el AI employee de tipo JSON sí despacha desde el Tester, a la URL que configuraste — es lo que te deja probar tu integración de punta a punta.Cuando eso pasa, el pago existe de verdad en nuestro sistema pero no representa dinero cobrado: es una corrida de prueba de tu propio equipo. Su folio sale de la misma serie que los reales, así que dentro del objeto payment nada lo distingue.Ese campo refleja el dominio del pago, no el de la conversación en la que ocurrió: aunque viaje dentro de conversation_info, lo llena la fila del pago. Es lo correcto para decidir si asentar el cobro — y en el caso raro en que los dos no coincidan, el que manda para tu contabilidad es el del pago.Si reusas un solo parser para las dos vías, ramifica por conversation_info.is_tester antes de asentar el cobro. El cuerpo que manda el AI employee lleva además is_test: true en su raíz, pero eso está un nivel arriba del objeto data — si tu función recibe solo el paquete del pago, no lo ve.
Ese campo tiene dos formas, y el AI employee elige cuál según lo que escriba en él.
  • Si no escribe nada ahí, o escribe un texto, recibes el arreglo directo: "pago": [ { "payment": …, "contact_info": …, "conversation_info": … } ]
  • Si escribe un objeto con su propia lectura, el paquete se anida bajo data: "pago": { "tipo": "anticipo", "data": [ { "payment": … } ] }
La segunda forma existe para que el campo lleve junto lo que el AI entendió y el dato duro del pago, en vez de repartirlos en dos claves que tengas que cruzar por nombre.Acepta las dos: si el campo es un arreglo, ésos son los pagos; si es un objeto, están en su clave data. Un parser escrito solo contra la primera forma deja de encontrar el pago el día que el AI escriba un objeto ahí.Esa clave data no es el objeto data del webhook: es el arreglo de esos objetos. Y la misma regla aplica a los campos de archivos.
Las dos vías se complementan y por eso conviene tener ambas: este webhook cubre los desenlaces —un pago que tu equipo acredita días después, cuando ya nadie está conversando—, y el del AI employee cubre el contexto del momento de la conversación.
Disponibilidad: esta vía se está habilitando por etapas a nivel de la plataforma, no cuenta por cuenta. Escríbenos si quieres que te avisemos cuando esté publicada.

fail_reason: texto libre escrito por una persona

En payment.failed, fail_reason trae el motivo que tu equipo escribió a mano al marcar el pago como fallido — por qué no pudieron verificarlo. Es prosa en el idioma de quien lo escribió, no un código de una lista cerrada: no lo uses como llave de una tabla de traducciones ni intentes parsearlo. El panel lo exige antes de dejar marcar el pago, pero haz null-check igual: el endpoint todavía lo acepta vacío mientras dura el despliegue de esta función. En cualquier otro evento es null. Como payment.failed cubre solo pagos manuales (los fallos de pasarela no emiten webhook), nunca vas a recibir un código de proveedor por este campo.
Trátalo como texto de un humano, no confiable para lógica: escápalo antes de renderizarlo en HTML y no derives decisiones automáticas de su contenido. Puede traer saltos de línea, acentos y emoji. Su largo está acotado a 300 caracteres, pero dimensiona tu almacenamiento por el límite, no por lo que veas en las primeras entregas.
conversation_info.uuid puede ser null si la conversación asociada se borró — el pago igual notifica (el webhook es del ciclo de vida del pago, no de la conversación). Correlaciona siempre por data.payment.uuid.

Headers de cada entrega

string
Id único de la entrega. Estable entre reintentos de un mismo evento — úsalo para deduplicar (ver Entrega).
integer
Instante de envío (segundos Unix). Se recomputa en cada reintento y entra en la firma. Rechaza los que estén fuera de una ventana razonable (±5 min) para protegerte de replays.
string
Una o más firmas v1,<base64> separadas por espacio (habrá dos durante una rotación de secret). La entrega es válida si alguna coincide.
string
El tipo de evento (payment.credited, payment.failed, …). Coincide con type del cuerpo.
string
Siempre 1to1-Webhooks/1.0.

Verificar la firma

La firma es un HMAC-SHA256 del contenido {webhook-id}.{webhook-timestamp}.{body}, donde body es el cuerpo crudo exacto que recibiste (no lo re-serialices). La clave es tu whsec_… con el prefijo removido y el resto decodificado de base64.
Verifica sobre el cuerpo crudo (raw body), antes de parsear el JSON. Re-serializar el objeto cambia bytes (espacios, orden de llaves) y rompe la firma.
La forma más simple es la librería oficial de Standard Webhooks, que maneja la rotación y la comparación en tiempo constante por ti:
Si prefieres verificar a mano (sin dependencias), replica el HMAC y compara en tiempo constante contra cada firma del header:

Entrega y reintentos

1

Responde 2xx rápido

Contesta con cualquier 2xx en cuanto recibas el webhook. Si tardas más de 10 segundos o respondes otro código, lo tratamos como fallo y reintentamos.
2

Deduplica por webhook-id

La entrega es at-least-once: un mismo evento puede llegar más de una vez (un reintento tras un timeout, por ejemplo). El webhook-id es estable entre reintentos — guárdalo y descarta los repetidos.
3

No asumas orden

Los eventos no llegan garantizadamente en orden. Usa data.payment.uuid
  • status como la verdad, no el orden de llegada: un payment.registered que llegue tarde no debe pisar un payment.credited que ya procesaste.
4

Reintentos ~3 días

Un endpoint caído recibe reintentos con espaciado creciente durante ~3 días. Si sigue fallando, la suscripción se deshabilita automáticamente (tras 5 fallos consecutivos agotados) — la reactivas desde el dashboard.

Rotar el secret

Desde el dashboard puedes rotar el signing secret cuando quieras. Durante una gracia de 24 horas, cada webhook se firma con el secret nuevo y el anterior a la vez (dos firmas en el header). Así actualizas tu sistema sin perder ni rechazar eventos: valida contra cualquiera de las dos; cuando vence la gracia de 24 h, el secret anterior deja de firmarse automáticamente.

Comprobantes

En un pago manual, files[] lista los comprobantes subidos con referencias estables — uuid, name y mime_type — pero sin URL de descarga: el payload es un snapshot que se reintenta durante días y una URL firmada expiraría en el camino. Para descargar el archivo, pide un enlace firmado con la misma API key de tu integración — no requiere configuración nueva. El {paymentUuid} del path es data.payment.uuid y el {fileUuid} es el uuid del comprobante dentro de data.payment.files[], ambos tomados del propio evento:
La respuesta trae la URL firmada y su vencimiento:
El download_url vence a los 30 minutos (expires_at); si expira antes de que lo uses, repite el mismo GET para obtener uno nuevo.
Trátalo como una credencial temporal: vale 30 minutos, no lo publiques, no lo reenvíes, no lo dejes en logs ni en tickets. Es un enlace privado portador — quien lo tenga descarga el comprobante sin necesitar tu API key — y revocar la key no invalida las URLs ya emitidas.
El endpoint responde 404 en dos casos, ambos terminales — ver el detalle en el catálogo de errores: PAYMENT_NOT_FOUND si el paymentUuid no resuelve para tu API key, y FILE_NOT_FOUND si el fileUuid no es un comprobante confirmado de ese pago.

Próximos pasos

Autenticación

Cómo se autentica tu integración con la API key.

Errores

El contrato de errores de la API pública.