Saltar al contenido principal
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 completo —payment, 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.

Lo que responde tu endpoint puede continuar la conversación

Cuando el AI employee de tipo JSON despacha a tu URL —o una acción send_json, que te manda una petición fija sin que la redacte un modelo—, lo que tu servidor conteste puede ser leído por otro empleado AI para seguirle escribiendo al cliente, si el equipo del negocio configuró ese procesamiento: en el empleado JSON desde su panel, o en la propia acción. No es automático: viene apagado. Una vez configurado en el empleado JSON, no importa cómo se lanzó: desde el panel, como acción de otro empleado AI, dentro de una campaña o desde esta API (run-ai, ai-assistance). Esto no cambia el contrato de esta API: mismos shapes, mismos status, mismos códigos de error, y tu respuesta se sigue registrando igual que hoy. Lo único distinto es qué hacemos nosotros con ella. Aun así, hay cinco cosas que conviene saber antes de decidir qué devuelves:
  • El cuerpo de tu respuesta se le muestra a un modelo. Contesta solo con lo que te parezca bien que el cliente llegue a saber: ni credenciales, ni datos de otro cliente, ni volcados internos. Si es largo, se recorta. Antes de mostrárselo tapamos los valores de los campos con nombre de credencial (token, password, api_key…), los números de 18 dígitos —aunque vengan partidos por espacios o guiones— y la parte /pay/… de los enlaces, así que si tu respuesta trae una CLABE o un enlace con /pay/…, el AI no podrá pasárselos al cliente. Ese filtro es una red y no una garantía: no reconoce todos los nombres de campo ni los enlaces de otras pasarelas, así que no mandes credenciales ni datos bancarios confiando en él.
  • Solo se lee si respondes 2xx y con cuerpo, y ni así es seguro. Un error o una respuesta vacía no continúan nada, y ese despacho no se repite si respondes 4xx o 5xx. Solo se repite una vez si no respondes en 30 segundos o falla la conexión, así que puede llegarte dos veces y sin clave de idempotencia: este despacho no lleva webhook-id ni firma, así que la deduplicación corre por tu cuenta. Los reintentos, el timeout y el webhook-id de Entrega y reintentos son solo de los webhooks de eventos de pago. Sí se continúa aunque en ese momento la conversación la atienda una persona o esté pendiente. No hay un plazo garantizado entre tu respuesta y el mensaje al cliente.
  • La cadena tiene un tope, y depende de dónde salió el despacho. Si la continuación vuelve a despachar a tu URL, esa respuesta también puede continuar la conversación, hasta el tope:
    • Hasta dos continuaciones cuando el despacho sale de lo que abre el turno: el empleado JSON que corre por sí mismo —el asignado, o uno lanzado directamente desde el panel, una campaña o esta API—, una acción send_json suelta en un grupo de acciones, o el action set del empleado que responde un mensaje del cliente, un mensaje programado o el botón «Ejecutar» sobre el asignado (con un send_json suyo o un empleado JSON que ese set lanza).
    • Una sola cuando despacha el action set de un empleado que otro lanzó: otro action set, esta API (run_ai, ai_assistance, assign_ai_employee con run: true o Smart Actions), una campaña, o «Ejecutar» sobre un empleado que no es el asignado. También se queda en una el empleado JSON que ese set lance.
    El tope es por cadena, no por conversación: cada mensaje del cliente la vuelve a empezar.
  • Una campaña te despacha una vez por destinatario. Si el equipo del negocio pone un send_json o un empleado JSON en una campaña, te llega una petición por destinatario, y con el procesamiento configurado cada respuesta puede continuar su conversación. Un send_json que va antes de la primera acción de AI de la campaña sale al ritmo del envío. Si va después, sale desde una cola que acota cuántos arrancan a la vez por negocio, pero no cuántos siguen corriendo: uno que tarda puede liberar su lugar antes de terminar. No hay un ritmo garantizado. Dimensiona tu endpoint por el tamaño de la audiencia, no por el de una conversación.
  • No aplica a los despachos que llevan el campo del pago, aunque no traiga ningún pago (un arreglo vacío, o un objeto con data vacío). Ahí la respuesta no se lee, por diseño: un endpoint que hace eco del request devolvería datos bancarios, y el corte no distingue un campo sin pagos de uno con pagos.
Disponibilidad: se está habilitando por etapas a nivel de la plataforma, no cuenta por cuenta.

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.
5

Un aviso puede llegar tarde

Si una entrega no se generó por una falla de nuestro lado, podemos recuperarla, normalmente en unos 15 minutos (hasta 3 días después del evento). El aviso recuperado trae en event_at la hora original del evento, no la de envío: usa event_at para fechar el pago, nunca la hora en que recibes el webhook. Al reactivar una suscripción pausada o deshabilitada, o al agregarle un evento, puedes recibir de golpe avisos de pagos de hasta 3 días atrás. El objeto payment trae el estado del pago al enviarlo, no el del evento: un payment.registered recuperado puede llegar con status: "credited" o "failed". No es un cobro nuevo: como en «No asumas orden», usa data.payment.uuid + status como la verdad.

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.