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

What your endpoint answers can continue the conversation

When the JSON-type AI employee dispatches to your URL —or a send_json action, which sends you a fixed request without a model drafting it—, whatever your server answers can be read by another AI employee to keep writing to the customer, if the business team set up that processing: on the JSON employee from their panel, or on the action itself. It is not automatic: it is disabled by default. Once configured on the JSON employee, it does not matter how it was launched: from the panel, as an action of another AI employee, inside a campaign, or from this API (run-ai, ai-assistance). This does not change this API’s contract: same shapes, same statuses, same error codes, and your response is still logged exactly as today. The only difference is what we do with it. Even so, there are five things worth knowing before deciding what you return:
  • Your response body is shown to a model. Only return what you are fine with the customer eventually learning: no credentials, no other customer’s data, no internal dumps. If it is long, it gets truncated. Before showing it we mask the values of credential-named fields (token, password, api_key…), 18-digit numbers —even when split by spaces or hyphens— and the /pay/… part of links, so if your response carries a CLABE or a link with /pay/…, the AI will not be able to pass it on to the customer. That filter is a safety net, not a guarantee: it does not recognize every field name, nor payment links from other gateways, so do not send credentials or bank details relying on it.
  • It is only read if you answer 2xx and with a body, and even then it is not guaranteed. An error or an empty response continues nothing, and that dispatch is not repeated if you answer 4xx or 5xx. It is repeated once only if you do not answer within 30 seconds or the connection fails, so it can reach you twice and with no idempotency key: this dispatch carries no webhook-id and no signature, so deduplicating is on you. The retries, the timeout and the webhook-id in Delivery and retries belong only to the payment event webhooks. It does continue even if at that moment a person is handling the conversation or it is pending. There is no guaranteed timeframe between your response and the message to the customer.
  • The chain has a cap, and it depends on where the dispatch came from. If the continuation dispatches to your URL again, that response can continue the conversation too, up to the cap:
    • Up to two continuations when the dispatch comes from what opens the turn: the JSON employee running on its own —the assigned one, or one launched directly from the panel, a campaign or this API—, a standalone send_json action in an action group, or the action set of the employee answering a customer message, a scheduled message or the “Run” button on the assigned employee (with its own send_json or a JSON employee that set launches).
    • Only one when the action set of an employee that something else launched dispatches: another action set, this API (run_ai, ai_assistance, assign_ai_employee with run: true or Smart Actions), a campaign, or “Run” on an employee that isn’t the assigned one. A JSON employee that set launches also stays at one.
    The cap is per chain, not per conversation: every customer message starts it over.
  • A campaign dispatches to you once per recipient. If the business team puts a send_json or a JSON employee in a campaign, you get one request per recipient, and with the processing configured each response can continue its conversation. A send_json placed before the campaign’s first AI action goes out at the send’s pace. If it comes after, it goes out from a queue that caps how many start at once per business, but not how many are still running: a slow one can free its place before it finishes. There is no guaranteed pace. Size your endpoint for the size of the audience, not for that of a single conversation.
  • It does not apply to dispatches that carry the payment field, even if it holds no payment (an empty array, or an object with an empty data). There the response is not read, by design: an endpoint that echoes the request would return bank details, and the cutoff does not tell a field without payments from one with payments.
Availability: this is being enabled in stages at the platform level, not account by account.

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

A notice may arrive late

If a delivery wasn’t created because of a failure on our side, we may recover it, usually within about 15 minutes (up to 3 days after the event). A recovered notice carries the original event time in event_at, not the send time: use event_at to date the payment, never the time you receive the webhook. When you re-enable a paused or disabled subscription, or add an event to it, you may receive notices for payments from up to 3 days back at once. The payment object carries the payment’s state when it is sent, not at the time of the event: a recovered payment.registered may arrive with status: "credited" or "failed". It is not a new payment: as in “Don’t assume order”, use data.payment.uuid + status as the source of truth.

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.