Skip to main content
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.
These webhooks are outbound (1to1 → your server). You don’t call them: you receive them. Every delivery is signed (the Standard Webhooks spec) so you can verify it came from us and wasn’t tampered with.

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

Event catalog

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

The payload

Each POST carries a JSON body with this shape. The example is a credited manual payment:
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.
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.
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.

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

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 objectpayment, 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.
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.
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.
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.
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.

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

Headers on every delivery

string
Unique delivery id. Stable across retries of the same event — use it to deduplicate (see Delivery).
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.
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.
string
The event type (payment.credited, payment.failed, …). Matches type in the body.
string
Always 1to1-Webhooks/1.0.

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.
Verify against the raw body, before parsing the JSON. Re-serializing the object changes bytes (whitespace, key order) and breaks the signature.
The simplest way is the official Standard Webhooks library, which handles rotation and constant-time comparison for you:
If you’d rather verify by hand (no dependencies), replicate the HMAC and compare in constant time against each signature in the header:

Delivery and retries

1

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

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

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

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.

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:
The response carries the signed URL and its expiration:
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.
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.
The endpoint returns 404 in two cases, both terminal — see the details in the error catalog: 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

Authentication

How your integration authenticates with the API key.

Errors

The public API’s error contract.