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.
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
EachPOST 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.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:
descriptionis 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:nullwhen nobody wrote anything.custom_fieldsare 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.
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 {}.
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.
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": … } ] }
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.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 asend_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
2xxand 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 answer4xxor5xx. 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 nowebhook-idand no signature, so deduplicating is on you. The retries, the timeout and thewebhook-idin 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_jsonaction 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 ownsend_jsonor 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_employeewithrun: trueor 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.
- 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
-
A campaign dispatches to you once per recipient. If the business team puts a
send_jsonor a JSON employee in a campaign, you get one request per recipient, and with the processing configured each response can continue its conversation. Asend_jsonplaced 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.
Headers on every delivery
Unique delivery id. Stable across retries of the same event — use it to
deduplicate (see Delivery).
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.
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.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.
The simplest way is the official Standard Webhooks library, which handles
rotation and constant-time comparison for you:
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:
download_url expires after 30 minutes (expires_at); if it expires
before you use it, repeat the same GET to get a fresh one.
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.