# Webhooks

> Receive signed events when payments succeed, invoices are paid and subscriptions change, and use them to fulfil orders.

Source: https://docs.borga.is/webhooks

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](/test-mode).

## Event shape

```json payment.succeeded
{
  "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](/api/events) 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.

```ts Next.js
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>;
```
```ts Express
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);
  }
}
```
```ts Manual (Node)
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](/api/events).

## 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.
