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.

Two things to get right
Verify the signature before you trust a payload. Deduplicate on event_id, because a webhook can arrive more than once.

Where we send

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_typeSent when
transaction.completedThe payment settled.
transaction.failedThe payer tried and their bank or the rail refused. data.error.reason says why.
transaction.canceledNobody paid: you canceled, the QR expired, or the payer declined. data.cancellation.reason says which.
transaction.refundedA 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}.

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

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"))
}
Use the raw body
Verify first, parse the JSON after. Any change to the bytes, such as pretty-printing or reordering keys, breaks the HMAC. Most frameworks need a setting to keep the raw body; check yours.

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:

StrategyDelays between attemptsAttemptsTotal
standard, the defaultNow, 5 min, 30 min, 2 h, 5 h, 10 h, then 12 h each16about 5.7 days
extendedSame, with one more 12 h attempt17about 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:

If you miss one

GET /v1/transactions/{id} always has the current state. Poll it for any transaction you have not heard about.