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

> 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 | paypal | ...); never "manual"
      "bank": null,                 // bank (SPEI-push); null for manual and card
      "bank_account": "BANORTE0876", // 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,       // online: provider id; manual: bank id captured when crediting (null if not captured)
      "receipt_url": null,          // null for manual; receipt page for credited online
      "fail_reason": null,          // only set on payment.failed: the FREE-TEXT reason your team wrote
      "credited_at": "2026-07-22T14:47:38.204Z",
      "description": "Partial payment for order 4471",  // payment note written by your team or your AI employee; null if none
      "custom_fields": {                                // the custom fields you defined; {} if there are none
        "Payment type": "Installment",
        "Order": "4471"
      }
    },
    "conversation_info": {
      "uuid": "c9ba...",            // null if the conversation was deleted
      "phone": "529671941293",
      "inbox_status": "pending",
      "is_tester": false,           // false on these webhooks: they never emit test payments
      "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 the phone if there is no BSUID yet)
    }
  }
}
```

<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. `"BANORTE0876"`),
`bank_datetime` (bank date/time, optional), and `files` (uploaded receipts). On
online payments they're `null` / `[]`. On manual payments, `transaction_id` is
the bank id captured by the operator when crediting (`null` if not captured);
on online payments it's the provider's id. On **every credited online
payment** `receipt_url` always carries a URL on our domain
(`https://…/pay/receipt/<uuid>`), never the provider-hosted receipt; on manual
payments it's `null`. That page requires no authentication and has no
time-based expiry (unlike the provider's own links); it stops serving if the
payment is deleted or stops being credited. The receipt PDF is downloaded by
appending the `/pdf` suffix to that same URL.

<Note>
  The payload **grows over time**: we may add new fields to the webhook without prior
  notice, and that doesn't count as a breaking change. The ones documented here won't be
  removed or renamed without announcing it first, and a breaking change arrives as a **new
  event type**, never by altering an existing one.

  That's why your parser must **ignore what it doesn't recognize** —both keys and event
  types— instead of failing: don't set `additionalProperties: false` in your schema
  validation, and don't use structs that reject unknown fields. A delivery your endpoint
  rejects is retried for \~3 days and can end up disabling the subscription.

  This applies to the payload's **shape**. Size limits and the contents of free-text fields
  may change: if you sized your storage by the caps above, check this guide before assuming
  they're still the same.
</Note>

### `description` and `custom_fields`: the context your team writes

Two fields that describe **what the payment is about**, taken from what your own business
captures:

* **`description`** is the payment note. Whoever registers the payment writes it from the
  panel, or your AI employee drafts it if you configured a guide for that. It is free
  prose: `null` when nobody wrote anything.
* **`custom_fields`** are the custom fields you defined in your account —"Payment type",
  "Order", whichever you need— with the value captured on that payment. While the field is rolling
  out in stages the key **may not be there at all**; when it is, it's always an object: `{}`
  when none were captured.

The keys of `custom_fields` are the names you gave them, verbatim, so renaming a field in
your account changes the key on subsequent payments. If you map them into your system, map
by the name you use today and review that mapping whenever you rename.

When both keys are present, today only the manual payments lane populates them: on card and
link payments they arrive as `null` and `{}`.

<Warning>
  Treat them as **human text, not something to branch on**: escape them before
  rendering them as HTML, and don't derive automated decisions from `description`
  or from the values in `custom_fields`. They may contain line breaks, accents and
  emoji. `description` is capped at 2000 characters, each `custom_fields` value at
  500 and each field name at 100, with at most 50 fields per payment — size your
  storage by those caps, not by what you see in the first few deliveries.
</Warning>

<Note>
  **Availability:** these two fields are rolling out in stages **platform-wide**, not per
  account. If you don't see them in your deliveries yet, they aren't published yet — reach out
  and we'll let you know when they are.
</Note>

### If you receive events through two channels, deduplicate by `payment.uuid`

Besides these webhooks, a JSON-type AI employee can dispatch the payment to a URL of yours
as part of what it extracts from the conversation. If you use both channels, **the same
payment will reach you twice**, with the same `payment` object in each.

Deduplicate by `data.payment.uuid`, which is stable across both. Do not use the folio: card
and link payments do not always carry one.

**If you also use a JSON-type AI employee** that attaches the payment, what you receive inside its field is **this same complete `data` object** —`payment`, `contact_info` and `conversation_info`— so the code you already wrote to process it works there too. The only thing it does not carry is the envelope (`id`, `type`, `event_at`), because that one describes this particular delivery.

<Warning>
  **There, `conversation_info.is_tester` can arrive as `true`.** These webhooks never send you test payments, but the JSON-type AI employee **does dispatch from the Tester**, to the URL you configured — that is what lets you test your integration end to end.

  When that happens, the payment really exists in our system but **does not represent money collected**: it is a test run by your own team. Its folio comes from the same series as the real ones, so **nothing inside the `payment` object tells them apart**.

  That field reflects the **payment's** domain, not the conversation's: even though it travels inside `conversation_info`, it is filled from the payment row. That is the right one for deciding whether to book the charge — and in the rare case where the two disagree, the payment's is the one that governs your books.

  If you reuse a single parser for both paths, **branch on `conversation_info.is_tester` before booking the payment**. The body the AI employee sends also carries `is_test: true` at its root, but that sits one level above the `data` object — if your function receives only the payment package, it never sees it.
</Warning>

<Note>
  **That field has two shapes, and the AI employee picks which one based on what it writes there.**

  * If it writes nothing there, or writes text, you receive the array directly:
    `"payment": [ { "payment": …, "contact_info": …, "conversation_info": … } ]`
  * If it writes an object with its own reading, the package nests under `data`:
    `"payment": { "type": "deposit", "data": [ { "payment": … } ] }`

  The second shape exists so the field can carry what the AI understood together with the hard
  payment data, instead of splitting them across two keys you have to match by name.

  Accept both: **if the field is an array, those are the payments; if it is an object, they are
  under its `data` key**. A parser written only against the first shape stops finding the
  payment the day the AI writes an object there.

  That `data` key is not the webhook's `data` object: it is the **array** of those objects. The
  same rule applies to file fields.
</Note>

The two channels complement each other, which is why having both is worthwhile: this
webhook covers **outcomes** —a payment your team credits days later, when nobody is having
a conversation— and the AI employee's covers the **context** at the time of the conversation.

<Note>
  **Availability:** this channel is rolling out in stages **platform-wide**, not per account.
  Reach out if you'd like us to let you know when it's published.
</Note>

### `fail_reason`: free text written by a person

On `payment.failed`, `fail_reason` carries the **reason your team typed by hand**
when marking the payment as failed — why they couldn't verify it. It's prose in
whatever language the writer used, not a code from a closed list: don't use it as
a key into a translation table and don't try to parse it. The dashboard requires
it before letting anyone mark the payment, but **null-check it anyway**: the
endpoint still accepts requests without it while this feature rolls out. On every
other event it's `null`.

Since `payment.failed` covers **only** manual payments (gateway failures don't
emit a webhook), you will never receive a provider code through this field.

<Warning>
  Treat it as **human text, not something to branch on**: escape it before
  rendering it as HTML, and don't derive automated decisions from its content. It
  may contain line breaks, accents and emoji. Its length is capped at 300
  characters — size your storage by that cap, not by what you see in the first few
  deliveries.
</Warning>

<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. To download the file, request a signed link with the same API key your
integration already uses — no extra setup required. The path's `{paymentUuid}`
is `data.payment.uuid`, and `{fileUuid}` is the `uuid` of the receipt inside
`data.payment.files[]`, both taken from the event itself:

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

The response carries the signed URL and its expiration:

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

The `download_url` **expires after 30 minutes** (`expires_at`); if it expires
before you use it, repeat the same `GET` to get a fresh one.

<Warning>
  Treat it like a temporary credential: it's valid for 30 minutes, don't publish
  it, don't forward it, don't leave it in logs or support tickets. It's a private
  bearer link — whoever holds it can download the receipt without your API
  key — and revoking the key does **not** invalidate URLs already issued.
</Warning>

The endpoint returns `404` in two cases, both terminal — see the details in the
[error catalog](/en/errors): `PAYMENT_NOT_FOUND` if the `paymentUuid` doesn't
resolve for your API key, and `FILE_NOT_FOUND` if the `fileUuid` isn't a
confirmed receipt for that payment.

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