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

payment.succeededjson
{
  "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.

Next.jsts
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>;

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:

AttemptDelay after previous
21 minute
35 minutes
430 minutes
52 hours
66 hours
724 hours
848 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

GoalEvents
Fulfil orderspayment.succeeded, plus payment.failed to release stock
Track refundsrefund.created, payment.refunded, credit_note.created
Provision subscriptionssubscription.created, subscription.active, subscription.canceled, subscription.past_due
Nudge customers to update cardsinvoice.payment_failed, invoice.overdue
Monitor accounting syncinvoice.sync_failed
Warn about usageusage.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.