> ## Documentation Index
> Fetch the complete documentation index at: https://dev.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 | ...); nunca "manual"
      "bank": null,                 // banco (SPEI-push); null en manual y tarjeta
      "bank_account": "BANORTE 0876", // 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,       // id de transacción del proveedor (online)
      "receipt_url": null,          // recibo hosteado del proveedor (online), si existe
      "fail_reason": null,          // solo con valor en payment.failed
      "credited_at": "2026-07-22T14:47:38.204Z"
    },
    "conversation_info": {
      "uuid": "c9ba...",            // null si la conversación se borró
      "phone": "529671941293",
      "inbox_status": "pending",
      "is_tester": false,           // siempre false (los pagos de prueba no emiten)
      "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 teléfono como fallback)
    }
  }
}
```

<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.
`"BANORTE 0876"`), `bank_datetime` (fecha/hora en banca, opcional) y `files`
(comprobantes subidos). En pagos online quedan `null` / `[]`. Para tarjeta y
SPEI online usa `transaction_id` y `receipt_url`.

<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. La descarga del archivo se hace con tu API key por un endpoint
autenticado (se documentará aquí cuando esté disponible); mientras tanto, el
`uuid` te sirve para correlacionar el comprobante en el dashboard.

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