Webhooks
Borderli sends a signed HTTP POST to your server when a transaction reaches its final state. You do not have to poll. This page covers where we send, what we send, how you verify it, and what happens when your server does not answer.
event_id, because a webhook can arrive more than once.
Where we send
- Registered endpoints. The Borderli team registers one or more URLs for you during onboarding, together with the events you want and any headers your server needs. Registered endpoints get retries, delivery history and replay.
callback_url. Send acallback_urlin a/v1/payor/v1/executerequest and Borderli sends that transaction's final event there too. Handy for a first integration. The host must be one we allowlisted for you. A domain must usehttps. An allowlisted IP address, such as a staging box, may use plainhttp. Anything else fails the request with aValidationError.
Both paths sign with the same signing secret, which we give you during onboarding. Both go through the same queue: a delivery is written in the same database transaction as the settlement, survives a restart, and is retried until it succeeds or runs out of attempts.
Events
| event_type | Sent when |
|---|---|
transaction.completed | The payment settled. |
transaction.failed | The payer tried and their bank or the rail refused. data.error.reason says why. |
transaction.canceled | Nobody paid: you canceled, the QR expired, or the payer declined. data.cancellation.reason says which. |
transaction.refunded | A completed pay-in was refunded, in full or in part. data.status stays completed and data.result.refund.kind is full or partial. |
A registered endpoint receives the events it subscribed to, for example only transaction.completed, or * for everything including events we add later. A callback_url always receives the transaction's final event.
What we send
{
"schema_version": "1",
"event_id": "d5f3a2c1-4b8e-4f9a-b1c2-3d4e5f6a7b8c",
"event_type": "transaction.completed",
"occurred_at": "2026-07-01T10:15:30.000Z",
"data": {
"transaction_id": "txn_01J9QABCDEF001",
"instruction_id": "ins_01J9QABCDEF002",
"status": "completed",
"result": {
"settledAt": "2026-07-01T10:15:28.000Z",
"amount": { "value": 100700, "currency": "BRL" },
"charged": { "value": 100700, "currency": "BRL" },
"exchangeRate": null
},
"error": null,
"cancellation": null
}
}
Exactly one of result, error and cancellation is set, matching status. They are the same objects you get from GET /v1/transactions/{id}.
result.amountis what was paid on the QR side, in the QR currency.result.chargedis what the payer paid, in their currency. They differ only when currencies were converted, and thenresult.exchangeRateholds the rate. Money is always{ value, currency }withvaluein minor units.error.reasonisrejected,rail_failureortimeout. Which one a failed payment gets depends on the country and its rail. For a pay-in both mean the same thing: issue a new QR. For a pay-out,rejectedmeans the payer's bank refused and a retry with the same account will not help, whilerail_failuremay succeed on a retry.error.codeis the bank or scheme code when there is one.cancellation.reasonisrequested,expiredordeclined.
A failure:
{
"schema_version": "1",
"event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"event_type": "transaction.failed",
"occurred_at": "2026-07-01T10:16:00.000Z",
"data": {
"transaction_id": "txn_01J9QABCDEF003",
"instruction_id": "ins_01J9QABCDEF004",
"status": "failed",
"result": null,
"error": { "reason": "rejected", "code": "ACCOUNT_BLOCKED" },
"cancellation": null
}
}
A cancellation:
{
"schema_version": "1",
"event_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"event_type": "transaction.canceled",
"occurred_at": "2026-09-13T10:12:00.000Z",
"data": {
"transaction_id": "txn_01J9QABCDEF005",
"instruction_id": "ins_01J9QABCDEF006",
"status": "canceled",
"result": null,
"error": null,
"cancellation": { "reason": "expired" }
}
}
Verify the signature
Every delivery has a Borderli-Signature header:
Borderli-Signature: t=1751361330,v1=a3f8e2d1c4b5a6f7e8d9c0b1a2f3e4d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1
tis the Unix time, in seconds, of this delivery attempt.v1isHMAC_SHA256(signing_secret, "<t>.<raw_body>"), hex encoded.
The HMAC covers the exact bytes of the request body with the timestamp in front. It is computed fresh on every attempt.
import { createHmac, timingSafeEqual } from "node:crypto"
function verifyBorderliSignature(
secret: string,
rawBody: string,
signatureHeader: string,
toleranceSeconds: number = 300,
): boolean {
const tMatch = signatureHeader.match(/(?:^|,)t=(\d+)/)
const v1Match = signatureHeader.match(/(?:^|,)v1=([0-9a-f]+)/)
if (!tMatch || !v1Match) return false
const timestamp = parseInt(tMatch[1], 10)
const providedHex = v1Match[1]
// Reject old signatures so a captured request cannot be replayed later.
const nowSeconds = Math.floor(Date.now() / 1000)
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false
const expectedHex = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")
if (expectedHex.length !== providedHex.length) return false
return timingSafeEqual(Buffer.from(expectedHex, "hex"), Buffer.from(providedHex, "hex"))
}
Reject a delivery whose t is more than 300 seconds away from your clock.
Extra headers on your endpoint
If your server sits behind its own authentication, Borderli can send fixed headers with every delivery to a registered endpoint: a bearer token, an API key header, or basic auth. You give us the names and values during onboarding. The API never returns the values; GET /v1/webhook-endpoints shows names only.
Up to 20 headers and 8 KiB in total. Host, Content-Length, Content-Type and Borderli-Signature cannot be overridden. A callback_url gets the signature only, no custom headers.
Retries
A delivery counts as failed when your server answers with anything other than a 2xx, does not answer within 10 seconds, or cannot be reached. Borderli then retries on this schedule:
| Strategy | Delays between attempts | Attempts | Total |
|---|---|---|---|
standard, the default | Now, 5 min, 30 min, 2 h, 5 h, 10 h, then 12 h each | 16 | about 5.7 days |
extended | Same, with one more 12 h attempt | 17 | about 6.2 days |
Each delay gets up to 20% of random jitter. When the attempts run out the delivery is exhausted. You can replay it.
If an endpoint fails every delivery for 5 days in a row, Borderli disables it so the backlog stops growing. Contact the Borderli team to enable it again once your server is healthy.
Duplicates and order
A webhook can arrive more than once. For example, your server answered 2xx but the answer was lost on the way, so we retried. Webhooks can also arrive out of order. Store event_id with a unique constraint and skip events you have already seen:
app.post("/webhooks/borderli", async (req, res) => {
const sig = req.headers["borderli-signature"]
if (!verifyBorderliSignature(SIGNING_SECRET, req.rawBody, sig)) {
return res.status(401).send("invalid signature")
}
const event = JSON.parse(req.rawBody)
const seen = await db.webhookEvents.exists({ id: event.event_id })
if (seen) {
return res.status(200).send("duplicate") // 2xx stops the retries
}
await db.webhookEvents.insert({ id: event.event_id, processed_at: new Date() })
await handleEvent(event)
res.status(200).send("ok")
})
Answer 2xx quickly and do the heavy work afterwards. A slow handler looks like a failed delivery and gets retried.
See and resend deliveries
Three endpoints, all scoped to your API key:
GET /v1/webhook-endpoints: your registered endpoints. Header values are never shown.GET /v1/webhook-deliveries?transaction_id=txn_01J...&status=failed: every delivery we attempted, with attempts, last status code and last error. Filter bytransaction_idor bystatus:pending,delivering,delivered,failed,exhausted.POST /v1/webhook-deliveries/{id}/replay: send a delivery again. Works ondelivered,failedandexhausteddeliveries. Apendingordeliveringone is already on its way and cannot be replayed. Replaying a delivered event sends the sameevent_idagain, so your deduplication handles it.
If you miss one
GET /v1/transactions/{id} always has the current state. Poll it for any transaction you have not heard about.