BillKit/Docs Console
Build & operate

Webhooks

Almost everything that matters in billing happens without a request from you. Webhooks are how your application finds out.

Updated Sep 13, 2026 · API version 2026-09-13

Register an endpoint

Create one in the console under Developers → Webhooks, or over the API. Endpoints are per mode, so a test endpoint never receives live traffic.

shell
curl https://api.billkit.eu/v1/webhook_endpoints \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
  "url": "https://example.com/webhooks/billkit",
  "enabled_events": ["invoice.paid", "invoice.payment_failed", "subscription.updated"],
  "description": "production receiver"
}'

Omit enabled_events and it defaults to ["*"], which subscribes to everything. The response carries secret (a bkwhsec_... value) exactly once. Store it before you close the terminal; afterwards only secret_fingerprint, the first 12 hex characters of its digest, is readable.

The URL must resolve to a public address. BillKit re-resolves it and refuses to deliver to private, loopback, link-local or reserved IPs, and it re-checks at delivery time rather than only at creation, so pointing DNS at 127.0.0.1 after the fact does not work either.

Local development

Because localhost can never be a delivery target, use the CLI to stream events to your machine instead. It opens a server-sent-events connection to your account, signs each event with a locally generated secret, and POSTs it to your app.

billkit listen --forward-to http://localhost:3000/webhooks/billkit

It prints the signing secret to use for that session. That secret is generated locally and is different from the one on any registered endpoint, so point your app at it while you develop.

billkit trigger customer.created

trigger makes a real test-mode API call so the event is produced normally rather than faked. Full detail is in the CLI guide.

The event payload

Every delivery is the same envelope. data is the resource object itself, at the top level, not wrapped in a data.object. It is the whole object, exactly as the matching GET returns it; three fields of it are shown here to keep the envelope readable.

POST body
{
"id": "evt_Ck7Rn2vZ9xQ4mTpLbw",
"object": "event",
"type": "invoice.paid",
"created": 1789344500,
"livemode": false,
"customer": null,
"data": {
  "id": "inv_Wq3Ye8nB5tX1rKcMzv",
  "object": "invoice",
  "status": "paid"
}
}

Four headers travel with it:

HeaderValue
BillKit-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
BillKit-Event-IdThe evt_… id, for your own deduplication.
BillKit-Event-TypeSame as type in the body, so you can route before parsing.
BillKit-Delivery-Attempt1 on the first try, incrementing on each retry.

Verify the signature

Sign "{timestamp}.{raw body}" with the endpoint secret using HMAC-SHA256, hex-encode it, and compare in constant time. Use the exact bytes you received, before any JSON parsing: re-serialise the body and the signature will not match.

Node.js
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verify(rawBody, header, secret) {
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
  const i = part.indexOf("=");
  if (i < 0) continue;
  const key = part.slice(0, i).trim();
  const value = part.slice(i + 1).trim();
  if (key === "t") timestamp = Number.parseInt(value, 10);
  // A rotation sends two v1 values. Accept either.
  else if (key === "v1") signatures.push(value);
}
if (!timestamp || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

const expected = crypto
  .createHmac("sha256", secret)
  .update(`${timestamp}.`)
  .update(rawBody)
  .digest("hex");

return signatures.some((candidate) =>
  candidate.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)),
);
}

Reject anything older than five minutes. That window is what stops a captured delivery being replayed at you later.

The header can carry more than one v1=. During a secret rotation BillKit signs with both the old and the new secret. A verifier that reads only the first v1 will start rejecting deliveries mid-rotation.

Every official SDK ships a verifier, so you rarely need to write this yourself. See SDKs.

Rotating a secret

POST /v1/webhook_endpoints/{id}/rotate_secret returns a new bkwhsec_... once. The previous secret keeps verifying for 24 hours, which is the overlap you deploy in. Roll the new secret out, confirm deliveries are still landing, and the old one retires on its own.

Retries

Return any 2xx and the delivery is done. Anything else, including a timeout, is a failure and is retried on a fixed backoff.

AttemptDelay after the previous failure
25 seconds
330 seconds
45 minutes
530 minutes
64 hours

Six attempts total, spanning a little over four and a half hours. After the sixth the delivery is marked failed and your workspace owner gets an email, throttled to one per endpoint per hour so a broken receiver does not produce a mailbox full of alerts.

The request timeout is 10 seconds. Redirects are not followed.

Acknowledge first, then work. Write the event to a queue and return 200. A handler that provisions an account inline is a handler that eventually exceeds 10 seconds, and BillKit will retry a delivery your code has already processed.

Ordering and duplicates

Deliveries are dispatched concurrently and retried independently, so they are not ordered. Two rules cover this:

Deduplicate on BillKit-Event-Id. The same event can arrive more than once, most often when your receiver succeeded but the response did not get back in time.

Compare created against what you have stored, and ignore anything older. A retried subscription.updated from four hours ago must not overwrite a newer one that already landed.

Inspecting and replaying

Deliveries are a queryable ledger, which is usually faster than adding logging to find out why an event never arrived.

shell
curl https://api.billkit.eu/v1/webhook_endpoints/we_Lm2Qb7xR4nV9pTcKys/deliveries \
-H "Authorization: Bearer $BILLKIT_API_KEY"

Each row carries status, attempt_count, next_attempt_at, last_response_code, last_error and up to 2KB of last_response_body_excerpt, which is normally enough to see the stack trace your own app returned.

POST /v1/webhook_endpoints/{id}/deliveries/{delivery_id}/redeliver puts a row back on the queue for immediate retry. It is safe to call on anything: a delivered row is returned unchanged, and attempt_count is preserved as evidence rather than reset. This is the recovery path after a receiver outage.

Turning an endpoint off, and removing it

These are two different acts and both exist.

POST /v1/webhook_endpoints/{id} with status: "disabled" stops delivery and keeps everything else: the endpoint, its signing secret and its delivery history. status: "enabled" resumes. This is what you want during planned downtime, or while you work out why a receiver is failing.

DELETE /v1/webhook_endpoints/{id} removes it. GET /v1/webhook_endpoints/{id} returns 404 afterwards, it is gone from the list, and the response is {"id": "we_...", "object": "webhook_endpoint", "deleted": true} rather than the endpoint. Use it for a URL that should never have been registered.

Deleting takes the delivery ledger with it. Those rows are readable only through the endpoint that owns them, so nothing could fetch them afterwards. The events themselves are untouched and still in GET /v1/events, which is the record of what you were sent. If you want the delivery history, disable instead.

Events

This is the complete list of types BillKit emits. A name that is not on it is rejected when you create or update an endpoint, so a typo fails at registration instead of leaving you with an endpoint that never fires. GET /v1/webhook_endpoints/event_types returns the same list from the API if you would rather check against that than against this page.

EventFires when
checkout.session.completedA checkout payment settled. Provision here, not on success_url.
checkout.session.expiredThe session’s payment failed terminally or the session lapsed.
subscription.createdImmediately follows checkout.session.completed on the same payment.
subscription.updatedAny change to the subscription. The specific events below fire alongside this one rather than instead of it, so a handler on this event alone keeps working.
subscription.paused / .resumedBilling stopped without cancelling, or started again. A paused subscription stays status: active indefinitely, so read serves_customer rather than status to decide whether to keep serving it.
subscription.reactivatedA scheduled cancellation was withdrawn before it took effect.
subscription.plan_changedThe subscription moved to a different price.
subscription.trial_startedThe subscription was created inside a trial window.
subscription.trial_will_endThe trial ends within three days. Use it to warn the customer before they are charged.
subscription.trial_endedThe trial is over, either on the first paid renewal or via the backstop reaper.
subscription.past_dueA renewal failed. Start your dunning messaging.
subscription.canceledTerminal. Revoke access.
subscription.payment_method_updatedA reauthorization captured a new mandate.
subscription.coupon_applied / .coupon_expiredA discount attached to the subscription, or ran out.
invoice.createdAn invoice exists. Fires for a subscription charge, for a one-off sale, and at a metered period close.
invoice.paidThe invoice is settled. On a subscription charge this is where you extend access.
invoice.payment_failedA metered cycle charge failed against the mandate.
invoice.voidedThe invoice was voided: it was never owed. Stop treating it as a receivable. It is not a refund — money that already moved is reversed with a credit note instead.
invoice.marked_uncollectibleCollection was abandoned after the retry budget ran out.
payment.succeeded / payment.failedIndividual charge outcomes.
refund.created / .succeeded / .failedRefunds settle asynchronously. Do not treat created as money returned.
dispute.created / .closedA chargeback was received, or reversed in your favour.
credit_note.createdA credit note was issued against an invoice.
one_shot_payment.succeeded / .failed / .refundedOne-off, mandate-less charges.
customer.created / .updated / .deletedCustomer lifecycle. On a GDPR purge, deleted carries a pre-redaction snapshot so you can still match it.
product.created / .updated / .archivedCatalogue changes.
price.created / .archived / .updatedA price’s amount never changes. archived is it leaving sale, updated is it coming back.
coupon.created / .updatedA coupon was created, or changed, including active: false withdrawing the code.
webhook_endpoint.created / .updated / .deletedChanges to the endpoints themselves, including being disabled. A deleted endpoint never receives its own deleted event.
dunning.email_requiredA failed renewal needs a customer notice. Handle it only if you send your own dunning email instead of BillKit’s.

Some things you might expect as events are audit log entries instead, readable through GET /v1/audit_logs: coupon redemption, secret rotation, VAT validation outcomes, tax-rate changes, and the operator-side actions taken in the console. They record who did what, which is a different question from what your application needs to react to.

Events are also readable directly. GET /v1/events lists them and GET /v1/events/{id} fetches one. GET /v1/events/stream is the server-sent-events feed that billkit listen consumes; it starts from the newest event at connect time and does not replay history.

How long these are kept

Events: 90 days. GET /v1/events reaches back that far and no further; an older event id returns 404. The delivery ledger: 30 days, so a delivery row can age out while the event it delivered is still readable. That ordering is deliberate and is the same shape Stripe and Chargebee use — an attempt is only meaningful alongside the event it was an attempt to deliver, never the other way round.

Two consequences worth designing for:

  • Redelivery has a shorter horizon than the event log. A failed delivery stays redeliverable for as long as its row survives, so plan recovery from a long receiver outage in days, not months.
  • Events are not an archive. If you need event history beyond 90 days — for your own audit trail, analytics or reconciliation — persist what you care about on receipt. GET /v1/events is a recovery and debugging surface, not a system of record.

Issued accounting documents are the opposite case and are never deleted: invoices, credit notes, payments and refunds are kept indefinitely, because you are required to keep them. Self-hosted deployments can tune both windows.