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

> Receive signed notifications on your server when your payments change state: credited, failed, or registered.

**Webhooks** notify you in real time, on your own server, every time a payment
changes state — no polling required. 1to1 sends a signed `POST` to the URL you
register, with the payment details.

<Note>
  These webhooks are **outbound** (1to1 → your server). You don't call them: you
  receive them. Every delivery is **signed** (the [Standard Webhooks](https://www.standardwebhooks.com/)
  spec) so you can verify it came from us and wasn't tampered with.
</Note>

## Set up an endpoint

In the dashboard, under **Settings → API and Connections**, add your system's URL
and pick the events you want to receive. On creation you get a **signing secret**
(`whsec_…`) shown **only once** — store it as a secret: it's the key you verify
each webhook with. You can register up to **10 endpoints** per business.

<Warning>
  The URL must be **`https://`** and public. Keep the `whsec_…` in a safe place on
  the server (environment variable, secret manager) — never in client code or the
  repository. If you lose it, **rotate** the secret from the dashboard.
</Warning>

## Event catalog

| Event                | When it fires                                                                                |
| -------------------- | -------------------------------------------------------------------------------------------- |
| `payment.credited`   | A payment was **credited** — card (online), SPEI, or a manual payment credited by your team. |
| `payment.failed`     | A **manual** payment was marked failed by your team.                                         |
| `payment.registered` | A **manual** payment was recorded, pending crediting.                                        |
| `ping`               | Test event from the **Test** button in the dashboard.                                        |

<Note>
  `payment.failed` covers **only** manual payments marked failed by your team.
  Online payment failures (card declined, expired session) do **not** emit a
  webhook.
</Note>

## The payload

Each `POST` carries a JSON body with this shape. The example is a credited manual
payment:

```jsonc theme={null}
{
  "id": "8f3b2c1a-...",              // unique id of this delivery (= webhook-id header)
  "type": "payment.credited",
  "event_at": "2026-07-22T14:47:38.609Z",  // when the event happened; stamped at emit, shortly after credited_at
  "data": {
    "payment": {
      "uuid": "f893...",            // stable payment id (use it to correlate)
      "folio": "PAY-00000002",
      "status": "credited",         // credited | failed | pending
      "amount_cents": 15000,        // money is ALWAYS in cents + currency
      "credited_amount_cents": 15000,
      "currency": "MXN",
      "provider": "manual",         // manual | stripe | mercadopago | paypal
      "method": null,               // informational (card | spei | ...); never "manual"
      "bank": null,                 // bank (SPEI-push); null for manual and card
      "bank_account": "BANORTE 0876", // manual only: the account the money went into
      "bank_datetime": "2026-07-22T14:47:00Z", // manual only, optional
      "files": [                    // receipts (manual only); [] if none
        { "uuid": "92c9...", "name": "receipt.pdf", "mime_type": "application/pdf" }
      ],
      "transaction_id": null,       // provider transaction id (online)
      "receipt_url": null,          // provider-hosted receipt (online), if any
      "fail_reason": null,          // only set on payment.failed
      "credited_at": "2026-07-22T14:47:38.204Z"
    },
    "conversation_info": {
      "uuid": "c9ba...",            // null if the conversation was deleted
      "phone": "529671941293",
      "inbox_status": "pending",
      "is_tester": false,           // always false (test payments don't emit)
      "mailbox": { "name": "Sales" }
    },
    "contact_info": {
      "first_name": "Maikol",
      "middle_name": null,
      "last_name": null,
      "second_last_name": null,
      "full_name": "Maikol",
      "username": null,             // WhatsApp handle (without "@"), if known
      "phone": "529671941293",
      "whatsapp_user_id": "MX.1343..." // WhatsApp identity (or phone as fallback)
    }
  }
}
```

<Note>
  `event_at` is when the payment event happened (for `payment.failed`, the time of
  the failure). It's **different** from the `webhook-timestamp` header, which is the
  send time used for the anti-replay signature.
</Note>

Some fields are **manual-payment only** (`provider: "manual"`): `bank_account`
(the label of the account the money went into, e.g. `"BANORTE 0876"`),
`bank_datetime` (bank date/time, optional), and `files` (uploaded receipts). On
online payments they're `null` / `[]`. For card and online SPEI use
`transaction_id` and `receipt_url`.

<Warning>
  `conversation_info.uuid` can be **`null`** if the associated conversation was
  deleted — the payment still notifies (the webhook is about the **payment**'s
  lifecycle, not the conversation's). Always correlate by `data.payment.uuid`.
</Warning>

## Headers on every delivery

<ResponseField name="webhook-id" type="string">
  Unique delivery id. **Stable across retries** of the same event — use it to
  deduplicate (see [Delivery](#delivery-and-retries)).
</ResponseField>

<ResponseField name="webhook-timestamp" type="integer">
  Send time (Unix seconds). Recomputed on each retry and part of the signature.
  Reject any outside a reasonable window (±5 min) to protect against replays.
</ResponseField>

<ResponseField name="webhook-signature" type="string">
  One or more `v1,<base64>` signatures separated by a space (there'll be **two**
  during a secret rotation). The delivery is valid if **any** of them matches.
</ResponseField>

<ResponseField name="x-1to1-event" type="string">
  The event type (`payment.credited`, `payment.failed`, …). Matches `type` in the
  body.
</ResponseField>

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

## Verify the signature

The signature is an **HMAC-SHA256** of the content `{webhook-id}.{webhook-timestamp}.{body}`,
where `body` is the **exact** raw body you received (don't re-serialize it). The
key is your `whsec_…` with the prefix removed and the rest base64-decoded.

<Warning>
  Verify against the **raw body**, before parsing the JSON. Re-serializing the
  object changes bytes (whitespace, key order) and breaks the signature.
</Warning>

The simplest way is the official Standard Webhooks library, which handles
rotation and constant-time comparison for you:

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

  // whsec_… stored in an environment variable
  const wh = new Webhook(process.env.WEBHOOK_SECRET);

  // rawBody = the request's raw body (string), NOT the already-parsed object
  const payload = wh.verify(rawBody, {
    "webhook-id": req.headers["webhook-id"],
    "webhook-timestamp": req.headers["webhook-timestamp"],
    "webhook-signature": req.headers["webhook-signature"],
  });
  // If the signature doesn't validate, verify() throws — respond 400 and don't process.
  ```

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

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

  # raw_body = the request's raw body (bytes/str), NOT the already-parsed dict
  payload = wh.verify(raw_body, {
      "webhook-id": headers["webhook-id"],
      "webhook-timestamp": headers["webhook-timestamp"],
      "webhook-signature": headers["webhook-signature"],
  })
  # If the signature doesn't validate, verify() throws — respond 400 and don't process.
  ```
</CodeGroup>

If you'd rather verify by hand (no dependencies), replicate the HMAC and compare
in constant time against each signature in the 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"];

    // Reject replays outside ±5 min (the webhook-timestamp is part of the signature).
    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");

    // The header may carry several signatures (rotation), space-separated.
    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:
      # Reject replays outside ±5 min (the webhook-timestamp is part of the signature).
      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()

      # The header may carry several signatures (rotation), space-separated.
      received = [s.removeprefix("v1,") for s in headers["webhook-signature"].split(" ")]
      return any(hmac.compare_digest(sig, expected) for sig in received)
  ```
</CodeGroup>

## Delivery and retries

<Steps>
  <Step title="Respond 2xx fast">
    Reply with any `2xx` as soon as you receive the webhook. If you take longer
    than **10 seconds** or respond with another status, we treat it as a failure
    and retry.
  </Step>

  <Step title="Deduplicate by webhook-id">
    Delivery is **at-least-once**: the same event may arrive more than once (a
    retry after a timeout, for example). The `webhook-id` is stable across
    retries — store it and discard duplicates.
  </Step>

  <Step title="Don't assume order">
    Events are **not** guaranteed to arrive in order. Use `data.payment.uuid` +
    `status` as the truth, not arrival order: a late `payment.registered` must not
    override a `payment.credited` you already processed.
  </Step>

  <Step title="Retries ~3 days">
    A down endpoint gets retries with growing spacing over \~3 days. If it keeps
    failing, the subscription is **disabled** automatically (after 5 consecutive
    exhausted failures) — you re-enable it from the dashboard.
  </Step>
</Steps>

## Rotate the secret

You can **rotate** the signing secret anytime from the dashboard. During a
**24-hour grace period**, each webhook is signed with both the **new** and the
**previous** secret (two signatures in the header). This lets you update your
system without losing or rejecting events: validate against either one; when the
24-hour grace ends, the previous secret automatically stops signing.

## Receipts

On a manual payment, `files[]` lists the uploaded receipts with stable
references — `uuid`, `name`, and `mime_type` — but **without a download URL**: the
payload is a snapshot retried for days, and a signed URL would expire along the
way. Downloading the file is done with your API key through an authenticated
endpoint (documented here once available); in the meantime, the `uuid` lets you
correlate the receipt in the dashboard.

## Next steps

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/en/authentication">
    How your integration authenticates with the API key.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/en/errors">
    The public API's error contract.
  </Card>
</CardGroup>
