> ## Documentation Index
> Fetch the complete documentation index at: https://docs.1to1ai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Recibe notificaciones firmadas en tu servidor cuando tus cobros cambian de estado: acreditado, fallido o registrado.

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.

<Note>
  Estos webhooks son **salientes** (1to1 → tu servidor). No los llamas tú: los
  recibes. Cada entrega va **firmada** (estándar [Standard Webhooks](https://www.standardwebhooks.com/))
  para que verifiques que vino de nosotros y no fue alterada.
</Note>

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

<Warning>
  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.
</Warning>

## Catálogo de eventos

| Evento               | Cuándo dispara                                                                                 |
| -------------------- | ---------------------------------------------------------------------------------------------- |
| `payment.credited`   | Un pago quedó **acreditado** — tarjeta (online), SPEI, o pago manual acreditado por tu equipo. |
| `payment.failed`     | Un pago **manual** fue marcado como fallido por tu equipo.                                     |
| `payment.registered` | Un pago **manual** fue registrado, pendiente de acreditar.                                     |
| `ping`               | Evento de prueba del botón **Probar** del dashboard.                                           |

<Note>
  `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.
</Note>

## El payload

Cada `POST` lleva un cuerpo JSON con esta forma. El ejemplo es un pago manual
acreditado:

```jsonc theme={null}
{
  "id": "8f3b2c1a-...",              // id único de esta entrega (= header webhook-id)
  "type": "payment.credited",
  "event_at": "2026-07-22T14:47:38.609Z",  // momento del evento; se estampa al emitir, poco después de credited_at
  "data": {
    "payment": {
      "uuid": "f893...",            // id estable del pago (úsalo para correlacionar)
      "folio": "PAY-00000002",
      "status": "credited",         // credited | failed | pending
      "amount_cents": 15000,        // dinero SIEMPRE en centavos + currency
      "credited_amount_cents": 15000,
      "currency": "MXN",
      "provider": "manual",         // manual | stripe | mercadopago | paypal
      "method": null,               // informativo (card | spei | paypal | ...); nunca "manual"
      "bank": null,                 // banco (SPEI-push); null en manual y tarjeta
      "bank_account": "BANORTE0876", // solo manual: la cuenta a la que entró el dinero
      "bank_datetime": "2026-07-22T14:47:00Z", // solo manual, opcional
      "files": [                    // comprobantes (solo manual); [] si no hay
        { "uuid": "92c9...", "name": "comprobante.pdf", "mime_type": "application/pdf" }
      ],
      "transaction_id": null,       // online: id del proveedor; manual: id de banca capturado al acreditar (null si no se capturó)
      "receipt_url": null,          // null en manual; página del recibo en online acreditado
      "fail_reason": null,          // solo con valor en payment.failed: el motivo EN TEXTO LIBRE que escribió tu equipo
      "credited_at": "2026-07-22T14:47:38.204Z",
      "description": "Abono parcial del pedido 4471",  // nota del pago escrita por tu equipo o por tu AI employee; null si no hay
      "custom_fields": {                                // los campos personalizados que definiste; {} si no hay ninguno
        "Tipo de pago": "Abono",
        "Pedido": "4471"
      }
    },
    "conversation_info": {
      "uuid": "c9ba...",            // null si la conversación se borró
      "phone": "529671941293",
      "inbox_status": "pending",
      "is_tester": false,           // false en estos webhooks: no emiten pagos de prueba
      "mailbox": { "name": "Ventas" }
    },
    "contact_info": {
      "first_name": "Maikol",
      "middle_name": null,
      "last_name": null,
      "second_last_name": null,
      "full_name": "Maikol",
      "username": null,             // handle de WhatsApp (sin "@"), si se conoce
      "phone": "529671941293",
      "whatsapp_user_id": "MX.1343..." // identidad de WhatsApp (o el teléfono si aún no hay BSUID)
    }
  }
}
```

<Note>
  `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.
</Note>

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.

<Note>
  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.
</Note>

### `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 `{}`.

<Warning>
  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.
</Warning>

<Note>
  **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.
</Note>

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

<Warning>
  **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.
</Warning>

<Note>
  **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.
</Note>

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.

<Note>
  **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.
</Note>

### `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.

<Warning>
  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.
</Warning>

<Warning>
  `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`.
</Warning>

## Headers de cada entrega

<ResponseField name="webhook-id" type="string">
  Id único de la entrega. **Estable entre reintentos** de un mismo evento —
  úsalo para deduplicar (ver [Entrega](#entrega-y-reintentos)).
</ResponseField>

<ResponseField name="webhook-timestamp" type="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.
</ResponseField>

<ResponseField name="webhook-signature" type="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.
</ResponseField>

<ResponseField name="x-1to1-event" type="string">
  El tipo de evento (`payment.credited`, `payment.failed`, …). Coincide con
  `type` del cuerpo.
</ResponseField>

<ResponseField name="user-agent" type="string">
  Siempre `1to1-Webhooks/1.0`.
</ResponseField>

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

<Warning>
  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.
</Warning>

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:

<CodeGroup>
  ```js JavaScript theme={null}
  import { Webhook } from "standardwebhooks";

  // whsec_… guardado en una variable de entorno
  const wh = new Webhook(process.env.WEBHOOK_SECRET);

  // rawBody = el cuerpo crudo del request (string), NO el objeto ya parseado
  const payload = wh.verify(rawBody, {
    "webhook-id": req.headers["webhook-id"],
    "webhook-timestamp": req.headers["webhook-timestamp"],
    "webhook-signature": req.headers["webhook-signature"],
  });
  // Si la firma no valida, verify() lanza — responde 400 y no proceses.
  ```

  ```python Python theme={null}
  import os
  from standardwebhooks import Webhook

  wh = Webhook(os.environ["WEBHOOK_SECRET"])

  # raw_body = el cuerpo crudo del request (bytes/str), NO el dict ya parseado
  payload = wh.verify(raw_body, {
      "webhook-id": headers["webhook-id"],
      "webhook-timestamp": headers["webhook-timestamp"],
      "webhook-signature": headers["webhook-signature"],
  })
  # Si la firma no valida, verify() lanza — responde 400 y no proceses.
  ```
</CodeGroup>

Si prefieres verificar a mano (sin dependencias), replica el HMAC y compara en
tiempo constante contra cada firma del header:

<CodeGroup>
  ```js JavaScript theme={null}
  import crypto from "crypto";

  function verifyWebhook(rawBody, headers, secret) {
    const id = headers["webhook-id"];
    const ts = headers["webhook-timestamp"];

    // Rechaza replays fuera de ±5 min (el webhook-timestamp entra en la firma).
    if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) return false;

    const signedContent = `${id}.${ts}.${rawBody}`;

    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = crypto.createHmac("sha256", key).update(signedContent).digest("base64");

    // El header puede traer varias firmas (rotación), separadas por espacio.
    const received = headers["webhook-signature"]
      .split(" ")
      .map((s) => s.replace(/^v1,/, ""));

    return received.some((sig) => {
      const a = Buffer.from(sig);
      const b = Buffer.from(expected);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    });
  }
  ```

  ```python Python theme={null}
  import base64, hashlib, hmac, time

  def verify_webhook(raw_body: str, headers: dict, secret: str) -> bool:
      # Rechaza replays fuera de ±5 min (el webhook-timestamp entra en la firma).
      if abs(int(time.time()) - int(headers["webhook-timestamp"])) > 300:
          return False

      signed_content = f'{headers["webhook-id"]}.{headers["webhook-timestamp"]}.{raw_body}'

      key = base64.b64decode(secret.removeprefix("whsec_"))
      expected = base64.b64encode(
          hmac.new(key, signed_content.encode(), hashlib.sha256).digest()
      ).decode()

      # El header puede traer varias firmas (rotación), separadas por espacio.
      received = [s.removeprefix("v1,") for s in headers["webhook-signature"].split(" ")]
      return any(hmac.compare_digest(sig, expected) for sig in received)
  ```
</CodeGroup>

## Entrega y reintentos

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## 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:

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://app.1to1ai.com/api/v1/public/{slug}/payments/{paymentUuid}/files/{fileUuid}" \
    -H "Authorization: Bearer sk_1to1_tu_api_key"
  ```
</CodeGroup>

La respuesta trae la URL firmada y su vencimiento:

```json theme={null}
{
  "download_url": "https://...",
  "expires_at": "2026-07-22T15:17:38.609Z"
}
```

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.

<Warning>
  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.
</Warning>

El endpoint responde `404` en dos casos, ambos terminales — ver el detalle en el
[catálogo de errores](/es/errors): `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

<CardGroup cols={2}>
  <Card title="Autenticación" icon="key" href="/es/authentication">
    Cómo se autentica tu integración con la API key.
  </Card>

  <Card title="Errores" icon="triangle-exclamation" href="/es/errors">
    El contrato de errores de la API pública.
  </Card>
</CardGroup>
