Webhooks
Receive signed events when payments succeed, invoices are paid and subscriptions change, and use them to fulfil orders.
Webhooks are how Borga tells your system what happened. Whenever an object changes, Borga creates an Event (evt_…) and POSTs it to every endpoint you have registered for that event type. Fulfil orders from webhooks rather than from browser redirects: a redirect only proves the payer finished the checkout UI, while payment.succeeded proves the money moved, including for bank invoices paid days later.
Register an endpoint
Open Webhooks in the dashboard and add a public HTTPS URL. Choose the event types to receive, or leave the default * for all. The endpoint's signing secret is shown once when you create it; store it as an environment variable. Endpoints are per mode, so create one for test and one for live.
Localhost and private network addresses are rejected. During development, expose your local server with a tunnel and register the tunnel URL; see Test mode.
Event shape
{
"id": "evt_5Rt8Uv1Wx3Yz5Ab7Cd9Ef1Gh",
"type": "payment.succeeded",
"created_at": "2026-09-07T12:04:31.512Z",
"data": {
"id": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6",
"amount": 12900,
"currency": "ISK",
"status": "succeeded",
"customer": null,
"description": null,
"metadata": { "order_id": "5678" },
"collection_method": "card",
"refunded_amount": 0,
"external_reference": "order_5678",
"failure_reason": null,
"card_brand": "visa",
"card_last4": "4242",
"wallet_type": null,
"created_at": "2026-09-07T12:03:58.201Z",
"updated_at": "2026-09-07T12:04:31.498Z"
}
}data is the object the event is about, in the same shape the corresponding GET endpoint returns. Some lifecycle events send a smaller summary instead; the event catalog lists what each type carries. When in doubt, fetch the object by id, since a webhook describes a moment in time and the object may have changed since.
Verify the signature
Every delivery carries a Borga-Signature header of the form t=<unix seconds>,v1=<hex HMAC-SHA256>. The HMAC is computed over the timestamp, a period, and the exact request body, keyed with the endpoint's signing secret. Verify it before trusting the payload, and always verify against the raw body: re-serialised JSON will not match.
import { verifyWebhookSignature, WebhookVerificationError } from "@borga/node";
import type { Payment } from "@borga/node";
// app/api/borga/webhook/route.ts
export async function POST(request: Request) {
const rawBody = await request.text(); // verify the raw body, never re-serialised JSON
let event;
try {
event = verifyWebhookSignature({
body: rawBody,
signature: request.headers.get("Borga-Signature"),
secret: process.env.BORGA_WEBHOOK_SECRET!,
});
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response("Invalid signature", { status: 400 });
}
throw err;
}
switch (event.type) {
case "payment.succeeded": {
const payment = event.data as unknown as Payment;
await fulfillOrder(payment.external_reference, payment.id);
break;
}
case "payment.failed":
// Optional: notify the customer, release reserved stock.
break;
}
// 2xx tells Borga the event is handled. Anything else is retried.
return new Response("ok");
}
declare function fulfillOrder(orderRef: string | null, paymentId: string): Promise<void>;import { verifyWebhookSignature } from "@borga/node";
// Register with express.raw so req.body is the untouched Buffer.
// app.post("/borga/webhook", express.raw({ type: "*/*" }), handler);
export function handler(
req: { body: Buffer; header(name: string): string | undefined },
res: { sendStatus(code: number): void },
) {
try {
const event = verifyWebhookSignature({
body: req.body,
signature: req.header("Borga-Signature"),
secret: process.env.BORGA_WEBHOOK_SECRET!,
});
if (event.type === "payment.succeeded") {
// fulfil the order
}
res.sendStatus(200);
} catch {
res.sendStatus(400);
}
}import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
// Borga-Signature: t=1725710400,v1=5f1c…
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; // 5 min tolerance
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return (
expected.length === parts.v1?.length &&
timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"))
);
}verifyWebhookSignature from @borga/node checks the HMAC in constant time, rejects timestamps more than five minutes old (configurable with toleranceSeconds) and returns the parsed event. It throws WebhookVerificationError on any problem.
Respond and retry
Return any 2xx status once you have durably recorded or handled the event. Anything else, including a timeout after 10 seconds or a redirect, counts as a failure and Borga retries with this schedule:
| Attempt | Delay after previous |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours |
| 8 | 48 hours |
That is eight attempts over roughly three days. If the last fifteen deliveries to an endpoint all failed, the endpoint is disabled and shown as such in the dashboard; re-enable it there once fixed. The Webhooks page lists every delivery with its response code and body, and lets you retry any of them by hand.
Do not return 2xx for events you failed to process just to stop retries: that drops the event. Return a 5xx and fix the handler; the retry schedule gives you time.
Handle duplicates and order
Retries mean you can receive the same event twice, and two events for the same object can arrive out of order. Make handlers idempotent by keying on the event id or on the object's id and status, and prefer reading the current state from the API over trusting a sequence of webhooks.
Which events to listen for
| Goal | Events |
|---|---|
| Fulfil orders | payment.succeeded, plus payment.failed to release stock |
| Track refunds | refund.created, payment.refunded, credit_note.created |
| Provision subscriptions | subscription.created, subscription.active, subscription.canceled, subscription.past_due |
| Nudge customers to update cards | invoice.payment_failed, invoice.overdue |
| Monitor accounting sync | invoice.sync_failed |
| Warn about usage | usage.high_watermark |
The complete list with payloads is in the event catalog.
Security notes
Deliveries come from Borga's servers with User-Agent: Borga-Webhooks/1.0. Do not allow-list by IP; verify the signature instead. Rotating a signing secret under Webhooks → Rotate secret takes effect immediately, so deploy the new secret to your handler first and rotate right after.