Outbound Webhooks

Borderli can push signed HTTP notifications to your server when a transaction reaches a terminal state, so you don't have to poll for the outcome. This guide covers the delivery model, payload format, signature verification, retry behaviour, and the operations endpoints.

Polling still works
You can always poll GET /v1/transactions/{id} as a fallback to reconcile a transaction if you ever miss a delivery.
At-least-once delivery
Webhooks are delivered at least once. Use the event_id field to deduplicate on your side.

Delivery model

Borderli supports two notification paths:

Every delivery — registered or ad-hoc — is signed with your single tenant-level signing secret, which we generate and hand you during onboarding. One secret verifies every webhook we send you, regardless of which path or endpoint it came from.

Both paths use the same reliable outbox: deliveries are enqueued transactionally with the settlement, survive process crashes, and are retried by a background worker.

Event catalog

Event type Fired when
transaction.completed The payment settled successfully
transaction.failed The payment was definitively rejected or failed

Borderli fires an event for both the inbound-webhook settlement path (partner callback) and the polling path; every terminal transition triggers a delivery.

Per-endpoint subscription: each registered endpoint subscribes to a chosen subset of events, e.g. ["transaction.completed"] to receive only successes, or ["*"] for all events (current and future). Tell the Borderli team which events you want when we register your endpoint; only matching terminal transitions are delivered to that endpoint. The ad-hoc callback_url path always receives the transaction's terminal event.

Payload

The request body is JSON with the following shape:

{
  "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": {
      "reference": "ORD-123456",
      "settledAt": "2026-07-01T10:15:28.000Z"
    },
    "error": null
  }
}

For transaction.failed events, result is null and error is populated:

{
  "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": "rail_failure",
      "code": "ACCOUNT_BLOCKED",
      "partnerStatus": "FAILED"
    }
  }
}

data.result (present on success) and data.error (present on failure) mirror the transaction's stored fields:

Signature verification

Every delivery includes a Borderli-Signature header. It is a standard HMAC-SHA256 signature over the timestamped raw body; verify it with any HMAC library using your Borderli signing secret (recipe below).

Header format

Borderli-Signature: t=1751361330,v1=a3f8e2d1c4b5a6f7e8d9c0b1a2f3e4d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1

The signature is computed over the exact raw request body bytes with the timestamp prepended. It is regenerated fresh on every retry attempt (never stored).

Verification recipe (Node.js / Bun)

import { createHmac, timingSafeEqual } from "node:crypto"

function verifyBorderliSignature(
  secret: string,
  rawBody: string,
  signatureHeader: string,
  toleranceSeconds: number = 300,
): boolean {
  // Parse t= and v1= from the header
  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 stale signatures (replay protection)
  const nowSeconds = Math.floor(Date.now() / 1000)
  if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false

  // Recompute expected HMAC
  const message = `${timestamp}.${rawBody}`
  const expectedHex = createHmac("sha256", secret).update(message).digest("hex")

  // Constant-time compare (prevents timing attacks)
  if (expectedHex.length !== providedHex.length) return false
  return timingSafeEqual(
    Buffer.from(expectedHex, "hex"),
    Buffer.from(providedHex, "hex"),
  )
}
Use the raw body
Parse the JSON after verifying the signature. Any body transformation (pretty-printing, key reordering) will break the HMAC. Capture the raw bytes before parsing.

Tolerance window

The recommended tolerance is 300 seconds (5 minutes). Reject requests where |now − t| > 300 to protect against replay attacks.

Authenticating requests to your endpoint

Your server can authenticate our webhooks in two ways, used together or independently:

  1. Verify the signature. Reject any request whose Borderli-Signature does not verify (see above). This proves the request came from Borderli.
  2. Custom request headers. On a registered endpoint, Borderli can attach fixed headers to every delivery so your receiver authenticates the caller with your existing scheme:
    • Bearer token: Authorization: Bearer <token>
    • API key: X-Api-Key: <value> (any header name)
    • Basic auth: Authorization: Basic <base64>

You give us the header names and values during onboarding; they are sent on every delivery attempt and never returned by the API (the endpoint list shows header names only, never values). Limits: up to 20 headers, 8 KiB total. Reserved headers that cannot be set: Host, Content-Length, Content-Type, Borderli-Signature.

The ad-hoc callback_url path is signature-only and does not send custom headers.

Retry behaviour

Borderli retries failed deliveries automatically. A delivery fails if:

Named retry strategies

Strategy Schedule Max attempts Horizon
standard (default) Immediate, 5 min, 30 min, 2 h, 5 h, 10 h, then 12 h 16 ~5.7 days
extended Same ramp-up, longer 12 h tail 17 ~6.2 days

Delays include ±20% random jitter to spread load. Once all attempts are exhausted the delivery enters exhausted status. Use the replay endpoint to re-enqueue manually.

Endpoint auto-disable

If your endpoint produces only failures for 5 consecutive days, Borderli automatically disables it to stop accruing a backlog. Contact the Borderli team to re-enable it once your receiver is healthy.

At-least-once delivery & deduplication

Borderli guarantees at-least-once delivery: your handler may receive the same event more than once (for example, after a retry where your server returned 2xx but the response was lost in transit). Events are not guaranteed to arrive in order.

To deduplicate, persist the event_id (a UUID) with a unique constraint and check it before processing:

// Idempotent handler pseudocode
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 alreadyProcessed = await db.webhookEvents.exists({ id: event.event_id })
  if (alreadyProcessed) {
    return res.status(200).send("duplicate, ignored")  // return 2xx to stop retries
  }

  await db.webhookEvents.insert({ id: event.event_id, processed_at: new Date() })
  await handleEvent(event)

  res.status(200).send("ok")
})

Operations endpoints

All endpoints are scoped to your tenant (authenticated via x-api-key).

List registered endpoints

GET /v1/webhook-endpoints

Returns all your registered webhook endpoints. Custom header values are redacted.

List deliveries

GET /v1/webhook-deliveries?transaction_id=txn_01J...&status=failed

Filter by transaction_id and/or status (pending, delivering, delivered, failed, exhausted). Useful for debugging missed deliveries.

Replay a delivery

POST /v1/webhook-deliveries/{id}/replay

Re-enqueues a delivery that has reached a terminal state (delivered, failed, or exhausted): resets status to pending, attempts to 0, and next_attempt_at to now, and the worker delivers it again on the next tick. Replaying a delivered event is a supported re-send (deduplicate on event_id). A delivery that is still in flight (pending or delivering) cannot be replayed.

What's next