Webhooks
Almost everything that matters in billing happens without a request from you. Webhooks are how your application finds out.
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.
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.
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.
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.
{
"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:
| Header | Value |
|---|---|
| BillKit-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
| BillKit-Event-Id | The evt_… id, for your own deduplication. |
| BillKit-Event-Type | Same as type in the body, so you can route before parsing. |
| BillKit-Delivery-Attempt | 1 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.
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.
| Attempt | Delay after the previous failure |
|---|---|
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 4 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.
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.
| Event | Fires when |
|---|---|
| checkout.session.completed | A checkout payment settled. Provision here, not on success_url. |
| checkout.session.expired | The session’s payment failed terminally or the session lapsed. |
| subscription.created | Immediately follows checkout.session.completed on the same payment. |
| subscription.updated | Any 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 / .resumed | Billing 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.reactivated | A scheduled cancellation was withdrawn before it took effect. |
| subscription.plan_changed | The subscription moved to a different price. |
| subscription.trial_started | The subscription was created inside a trial window. |
| subscription.trial_will_end | The trial ends within three days. Use it to warn the customer before they are charged. |
| subscription.trial_ended | The trial is over, either on the first paid renewal or via the backstop reaper. |
| subscription.past_due | A renewal failed. Start your dunning messaging. |
| subscription.canceled | Terminal. Revoke access. |
| subscription.payment_method_updated | A reauthorization captured a new mandate. |
| subscription.coupon_applied / .coupon_expired | A discount attached to the subscription, or ran out. |
| invoice.created | An invoice exists. Fires for a subscription charge, for a one-off sale, and at a metered period close. |
| invoice.paid | The invoice is settled. On a subscription charge this is where you extend access. |
| invoice.payment_failed | A metered cycle charge failed against the mandate. |
| invoice.voided | The 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_uncollectible | Collection was abandoned after the retry budget ran out. |
| payment.succeeded / payment.failed | Individual charge outcomes. |
| refund.created / .succeeded / .failed | Refunds settle asynchronously. Do not treat created as money returned. |
| dispute.created / .closed | A chargeback was received, or reversed in your favour. |
| credit_note.created | A credit note was issued against an invoice. |
| one_shot_payment.succeeded / .failed / .refunded | One-off, mandate-less charges. |
| customer.created / .updated / .deleted | Customer lifecycle. On a GDPR purge, deleted carries a pre-redaction snapshot so you can still match it. |
| product.created / .updated / .archived | Catalogue changes. |
| price.created / .archived / .updated | A price’s amount never changes. archived is it leaving sale, updated is it coming back. |
| coupon.created / .updated | A coupon was created, or changed, including active: false withdrawing the code. |
| webhook_endpoint.created / .updated / .deleted | Changes to the endpoints themselves, including being disabled. A deleted endpoint never receives its own deleted event. |
| dunning.email_required | A 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
faileddelivery 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/eventsis 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.
BillKit