BillKit/DocsConsole
Build & operate

Webhooks

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

Updated Oct 14, 2026·OpenAPI v1.1.0

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.

A mode holds at most 16 endpoints, disabled ones included. Creating a seventeenth returns 409 webhook_endpoint_limit_reached; delete one you no longer use, or subscribe an existing endpoint to more events, since one endpoint can take any number of event types.

The URL is checked twice. When you create or update the endpoint, it must be http or https, on port 80 or 443 (or no port), and its host cannot be localhost or a private, loopback, link-local or reserved IP address written as a literal. Anything else is a 400 on url. A hostname is not looked up at that point. On every delivery BillKit resolves the hostname, refuses to send if any address it resolves to is not public, and connects only to the addresses it checked, so pointing DNS at 127.0.0.1 after the fact does not work either. A delivery refused this way fails with a last_error starting blocked_unsafe_url.

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 as it stood when the event was recorded; 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,
"data": {
  "id": "inv_Wq3Ye8nB5tX1rKcMzv",
  "object": "invoice",
  "status": "paid"
},
"previous_attributes": null
}

previous_attributes is on every event. On an update, such as subscription.updated and the specific subscription events that fire alongside it, customer.updated, price.archived or invoice.voided, it holds the fields that changed, with their values before the change. It is null when nothing changed or the event does not report a change, such as a .created event.

Four headers travel with it, alongside Content-Type: application/json and User-Agent: BillKit-Webhook/1.0:

HeaderValue
BillKit-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>, with a second v1 for 24 hours after a secret rotation.
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);
  // Accept any v1 that matches, so the parser never breaks on extra values.
  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.

Every server SDK ships a verifier that does exactly this, so you rarely need to write it yourself. See SDKs.

Rotating a secret

POST /v1/webhook_endpoints/{id}/rotate_secret returns a new bkwhsec_... once. For the next 24 hours BillKit signs every delivery with both secrets, so BillKit-Signature carries two v1 values and a receiver holding either secret verifies it. The endpoint shows when the window closes in previous_secret_retired_at, and which secret is on its way out in previous_secret_fingerprint. After that, deliveries carry the new signature only.

To rotate:

  1. Call rotate_secret and store the new secret.
  2. Deploy it to your receiver within 24 hours. The server SDKs’ verifiers accept any matching v1, so nothing else changes.

Rotating again inside the window replaces the older secret, so finish one rotation before starting the next.

From the dashboard, rotating asks you to confirm your password, authenticator or passkey first, as registering or re-pointing an endpoint does, because the new secret signs every future delivery. A call with an API key needs no confirmation.

The rotation itself is recorded as webhook_endpoint.updated and as a webhook_endpoint.secret_rotated audit log entry.

Retries

Return any 2xx and the delivery is done. Anything else, including a timeout, is a failure and is retried on a fixed backoff, plus up to 20% random jitter so a recovering receiver is not hit by its whole backlog at once.

AttemptDelay after the previous failure
25 seconds
31 minute
45 minutes
530 minutes
62 hours
75 hours
810 hours
924 hours
1030 hours

Ten attempts total, spanning about three days (72 hours before jitter, up to 86 with it), so a receiver that is down over a weekend still gets its events when it comes back. After the tenth 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 owner hears sooner too: once an endpoint has accepted nothing for an hour while its deliveries keep being refused, one earlier email says so, at most once a day per endpoint. The endpoint itself is never disabled for failing: new events keep being sent to it, and a failed delivery can be redelivered once you have fixed the receiver.

The request timeout is 10 seconds for the whole exchange, from connecting to the last byte of your response. If your status code arrived in time but the body did not, the status code is what counts. Redirects are not followed, and only the first 2 KB of your response body is kept on the delivery record. An endpoint URL may name no port other than 443 or 80, and must resolve to a public address.

Normally at most two deliveries to any one endpoint are in flight at once, at most three to your workspace in each mode, and at most four to your workspace across both modes; the rest wait their turn. A receiver that answers promptly never notices. One that holds every request close to the 10-second limit slows its own deliveries, not your other endpoints’ or anyone else’s.

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. A row that had used all ten attempts gets one more; if that fails it goes back to failed. This is the recovery path after a receiver outage.

A delivery that is being sent at the moment you call it returns 409 operation_pending. That attempt is already the send you asked for, so BillKit does not start a second one beside it. Retry once it has finished: if it succeeded the row comes back delivered and unchanged, otherwise it is queued again.

Redelivery against a disabled endpoint is refused with 409 webhook_endpoint_disabled. Enable it first.

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. Deliveries already queued for it are not sent: they are marked failed with a last_error saying the endpoint was disabled, and no failure alert goes out. status: "enabled" resumes new deliveries; redeliver the queued ones you still want. Use this 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 happened. 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 with a 400 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, sorted, with the * wildcard alongside it.

shell
curl https://api.billkit.eu/v1/webhook_endpoints/event_types \
-H "Authorization: Bearer $BILLKIT_API_KEY"
EventFires when
checkout.session.completedA checkout payment settled. Provision here, not on success_url.
checkout.session.expiredA hosted session’s payment failed, was cancelled, or expired at the payment provider, or an embedded session reached its expires_at (24 hours after creation) without being completed. A failed payment in an embedded session does not end it: the customer can retry until then.
subscription.createdImmediately follows checkout.session.completed on the same payment.
subscription.updatedAny change to the subscription. Every specific event below from .paused to .coupon_expired, except .trial_started and .trial_will_end, fires alongside a subscription.updated for the same change rather than instead of it, with the same previous_attributes. A handler on this event alone therefore still sees every cancellation and failed renewal. .trial_started arrives with subscription.created, and .trial_will_end is a reminder that changes nothing.
subscription.paused / .resumedBilling stopped without cancelling, or started again. A paused subscription keeps access until current_period_end; 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. Fires at most once per subscription: on the first paid renewal, when that first charge is declined, or, when it has not settled within a day of trial_end (a SEPA debit can take days), at that point. Whichever comes first sends it and the others do not. A trial cancelled or paused before it ended sends subscription.canceled at trial_end instead.
subscription.past_dueA renewal failed. data.next_payment_attempt is when BillKit retries it, or null when it will not. Start your dunning messaging.
subscription.canceledThe subscription is over. It fires at once when a refund ends it, failed renewals are given up on, or a metered subscription closes its final period. A cancellation or pause that ran to the end of its period fires it at current_period_end (at trial_end for a trial, with cancellation_reason period_end), or once a renewal charged before the cancel has settled. A subscription whose failed renewal was given up on has cancellation_reason payment_failed.
subscription.payment_method_updatedA reauthorization, or the activation of an imported subscription, captured a new mandate, or removing a saved method moved the subscription onto another one. data.payment_method is the new one and previous_attributes.payment_method the one it replaced (null after an import activation).
subscription.coupon_applied / .coupon_expiredA discount attached to the subscription, or ran out. On .coupon_applied, data.discount names the coupon and previous_attributes.discount is null. On .coupon_expired, data.discount is null and previous_attributes.discount is the coupon that ended.
invoice.upcomingA renewal will be charged soon: three days ahead (seven for an annual plan). data.amount_due_cents is the amount it will charge (data.tax_cents of it VAT) and data.next_payment_attempt when, in Unix seconds. Anything that moves the amount before the charge (a coupon, a plan change, the customer’s VAT id or country, a tax rate) sends it again with the new figure, within the hour. A SEPA direct debit is only collected three days after the customer was emailed the amount it takes: a change inside that window, or a switch to SEPA after the notice, sends the event again with a later next_payment_attempt, and the debit waits for it. A metered SEPA subscription gets it when its period closes, with data.invoice_id. The amount is fixed from the first attempt on; nothing is sent while the stored payment method is known to be unusable. The customer is emailed the same for SEPA direct debit and annual plans. There is no invoice yet; invoice.created follows the charge.
invoice.createdAn invoice exists. Fires for a subscription charge, for a one-off sale, and at a metered period close. It and every other invoice.* event but invoice.upcoming carry the invoice with its lines, as GET /v1/invoices/{id} returns it.
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_uncollectibleA metered period’s invoice was written off after its retry budget ran out. Metered only: a licensed renewal that fails has no invoice, and is reported by subscription.past_due and subscription.canceled.
payment.succeeded / payment.failedIndividual charge outcomes, for every charge type including the first checkout payment. A first payment that fails sends payment.failed; one the buyer abandons, or that expires, does not. A first payment has no subscription_id, so do not fall back to the customer’s other subscriptions when handling it.
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, which happens when a refund settles. See Invoices and credit notes.
one_shot_payment.succeeded / .failed / .refundedOne-off, mandate-less charges. See Charge once, without a subscription.
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 any other change: its metadata, tax behaviour, payment methods or refund policy, or it coming back on sale.
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. BillKit also emails the customer itself whenever the customer has an email address, and that email cannot be switched off, so use the event for your own records or in-app prompts; a separate email of yours would be a second notice. See Recover failed payments.

Some things you might expect as events are audit log entries instead, readable through GET /v1/audit_logs: VAT validation outcomes, tax-rate changes, API key creation and revocation, portal session creation, invoice emails, GDPR purges and data exports. They record who did what, which is a different question from what your application needs to react to. See Audit log.

Events are also readable without an endpoint: listed, fetched one at a time, or streamed live. See Events and audit log.

How long these are kept

The delivery ledger: 30 days. 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: 90 days, so a delivery row ages out while the event it delivered is still readable. After that, persist what you need on receipt. Events and audit log has the full picture.