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.
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.
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 as it stood when the event was recorded; three
fields of it are shown here to keep the envelope readable.
{
"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:
| Header | Value |
|---|---|
| BillKit-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>, with a second v1 for 24 hours after a secret rotation. |
| 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);
// 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:
- Call
rotate_secretand store the new secret. - 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.
| Attempt | Delay after the previous failure |
|---|---|
| 2 | 5 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 5 hours |
| 8 | 10 hours |
| 9 | 24 hours |
| 10 | 30 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.
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.
curl https://api.billkit.eu/v1/webhook_endpoints/event_types \
-H "Authorization: Bearer $BILLKIT_API_KEY"| Event | Fires when |
|---|---|
| checkout.session.completed | A checkout payment settled. Provision here, not on success_url. |
| checkout.session.expired | A 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.created | Immediately follows checkout.session.completed on the same payment. |
| subscription.updated | Any 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 / .resumed | Billing 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.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. 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_due | A renewal failed. data.next_payment_attempt is when BillKit retries it, or null when it will not. Start your dunning messaging. |
| subscription.canceled | The 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_updated | A 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_expired | A 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.upcoming | A 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.created | An 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.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 | A 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.failed | Individual 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 / .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, which happens when a refund settles. See Invoices and credit notes. |
| one_shot_payment.succeeded / .failed / .refunded | One-off, mandate-less charges. See Charge once, without a subscription. |
| 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 any other change: its metadata, tax behaviour, payment methods or refund policy, or it coming back on sale. |
| 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. 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.
BillKit