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