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.
GET /v1/transactions/{id} as a fallback to reconcile a transaction if you ever miss a delivery.
event_id field to deduplicate on your side.
Delivery model
Borderli supports two notification paths:
- Registered endpoints: the Borderli team registers a persistent callback URL for you during onboarding. You provide the URL, the events to subscribe to, and any custom auth headers. Registered endpoints get full delivery history + replay.
callback_urlfallback: include an optionalcallback_urlin yourPOST /v1/payorPOST /v1/executerequest. Borderli enqueues one ad-hoc delivery. Useful for quick integrations without endpoint pre-registration. The host (a domain or IP) must be one we allowlisted for you during onboarding. Domains must usehttps, while a specific IP we allowlist for you (e.g. an internal or staging receiver) may use plainhttp. Otherwise the request returns aValidationError.
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:
error.reasonis one ofrail_failure,canceled,expired,timeout, ordeclined.error.codeis the partner's raw error code and may benull;error.partnerStatusis the raw partner status string.result(present on success) containsreference(the settlement/order id),settledAt(ISO 8601 settlement time), and an optionalrefundobject ({ kind, at }) present only on refunds.
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
t: Unix timestamp (seconds) of this delivery attemptv1:HMAC_SHA256(signing_secret, "<t>.<raw_body>"), hex-encoded
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"),
)
}
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:
- Verify the signature. Reject any request whose
Borderli-Signaturedoes not verify (see above). This proves the request came from Borderli. - 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>
- Bearer token:
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:
- Your server returns a non-2xx HTTP status
- Your server does not respond within 10 seconds (configurable)
- A network error occurs
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
- Quickstart: make your first payment in 2 minutes
- Error Handling: error types and retry guidance
- API Reference: full endpoint documentation