Events and audit log
Events record what happened in your account, the audit log records who did it, and the export hands you your data and its events in one file.
The event object
Every change BillKit makes to one of your objects, whether you asked for it or a payment settled on its own, is recorded as an event. The same records drive your webhooks, so this is where you look when a delivery never arrived and you need to know whether the thing happened at all.
| Field | Notes |
|---|---|
| idstring | An evt_… id. Deduplicate on it. |
| typestring | One of the types in the event catalogue. |
| createdinteger | Unix seconds. |
| livemodeboolean | Test and live events are separate. A key only ever sees its own mode. |
| dataobject | The object the event is about, as it stood at that moment. It is a snapshot and does not follow later changes, so it will not match the object’s current state. The one exception is a GDPR purge, which redacts the customer’s personal data in the events already stored. |
| previous_attributesobject | On an update, the fields that changed with their values before the change. null when nothing changed. Always present. |
| customerobject | null unless you pass expand=customer. See below. |
A webhook delivery carries the same event with one difference: it has no customer key.
previous_attributes is on both, with the same meaning. See
The event payload.
Listing events
GET /v1/events returns your events newest first. type filters to one type, or a
comma-separated list of them (type=invoice.paid,invoice.voided), matched exactly; there are no
wildcards, and a type BillKit does not emit is a 400 rather than an empty page. limit is 1 to 100, default 10. For the next, older page, pass the last
id you received as starting_after.
expand=customer resolves the customer named by the snapshot’s customer_id, which is what makes
a feed readable. It comes back null for events whose snapshot names no customer, including the
customer.* events themselves, where the customer is data, and for a customer that has since
been deleted or purged.
curl "https://api.billkit.eu/v1/events?type=subscription.created&limit=1&expand=customer" \
-H "Authorization: Bearer $BILLKIT_API_KEY"{
"object": "list",
"has_more": true,
"data": [
{
"id": "evt_Ck7Rn2vZ9xQ4mTpLbw",
"object": "event",
"created": 1789344500,
"livemode": false,
"type": "subscription.created",
"data": {
"id": "sub_Pv9Kc3nX8mQ2rTyLbd",
"object": "subscription",
"customer_id": "cus_4Bd8Kq2mWn7xPz1Rfe",
"price_id": "price_7Hd3Wm9pQx2vRt5Kna"
},
"previous_attributes": null,
"customer": {
"id": "cus_4Bd8Kq2mWn7xPz1Rfe",
"object": "customer.summary",
"name": "Ada Lovelace",
"email": "ada@example.com",
"country_code": "NL"
}
}
]
}GET /v1/events/{id} fetches one, with the full data it carried. An id from another account
answers 404, as does one older than the retention window.
curl https://api.billkit.eu/v1/events/evt_Ck7Rn2vZ9xQ4mTpLbw \
-H "Authorization: Bearer $BILLKIT_API_KEY"Both need the events:read scope.
Streaming events
GET /v1/events/stream holds a connection open and sends each event as a
server-sent event (SSE) as it is
recorded, usually within a second. It is what billkit listen uses. Authenticate with the same
Authorization header as any other call. A browser EventSource cannot set that header, so run
the listener on a server.
curl -N "https://api.billkit.eu/v1/events/stream?type=invoice.paid,payment.failed&starting_after=evt_Ck7Rn2vZ9xQ4mTpLbw" \
-H "Authorization: Bearer $BILLKIT_API_KEY"id: evt_Wq3Ye8nB5tX1rKcMzv
event: message
data: {"id":"evt_Wq3Ye8nB5tX1rKcMzv","object":"event","type":"invoice.paid","created":1789344512,"livemode":false,"data":{...},"previous_attributes":null}
: keep-alive
event: timeout
data: {"message": "..."}Four kinds of frame arrive:
| Frame | Meaning |
|---|---|
| message | One event. data is exactly the body a webhook delivery of that event carries, previous_attributes included (null when nothing changed), so a handler tested with billkit listen sees the production payload. The frame’s id: is the event id. |
| : keep-alive | A comment line, sent after 15 seconds without events so proxies do not close the connection. Ignore it. |
| timeout | The stream closes itself after about an hour, busy or not, or sooner if it has fallen far behind a write that is taking a long time to commit. Reconnect with the last id you saw. |
| unauthorized | The key was revoked or the account suspended while the stream was open. Checked about every 30 seconds. Do not reconnect with the same key. |
type is an optional event type or comma-separated list of them, matched exactly, the same
parameter as on GET /v1/events. Omit it to receive everything; * is not accepted. An unknown type is a 400 that names it, rather than a stream
that silently stays empty.
Events arrive in the order they were recorded, and events recorded together (a
subscription.updated and the subscription.canceled that follows it) arrive in the order they
were written. An event is held back while an earlier write is still being committed, so it cannot
be overtaken by one that commits later. If that earlier write takes more than about 30 seconds,
the stream stops waiting: later events are sent ahead of it, and its events follow as soon as it
commits. Order is what gives way in that case, not delivery.
Resuming after a disconnect
Without a cursor the stream starts at the moment you connect and replays nothing. To pick up where
you left off, pass the last event id you processed as starting_after. Every event recorded after
it is sent in order, then the stream continues live. The standard Last-Event-ID header works the
same way and is used when starting_after is absent.
Any of your event ids is a safe cursor, not only one this stream sent you. An id from
GET /v1/events or from a webhook delivery works the same way, which is how you start a stream
from the newest event you already know about. An event recorded at the same moment as the one you
resume from can be sent again after a resume, so de-duplicate by event id, as you already do for
webhooks.
An id that is not one of your events in this mode is a 400 parameter_invalid whose param names
whichever of the two you sent. That includes an id that has aged out of retention, so a listener
that was down for more than 90 days cannot resume. Reconnect without a cursor, then page
GET /v1/events back from the newest event until you reach one you have already processed.
The stream is not a substitute for webhooks. Nothing on it is retried. Whatever is recorded while you are disconnected reaches you only if you resume from a cursor. For anything your business depends on, register a webhook endpoint.
Events and webhooks
Every event is recorded once and then delivered to each enabled endpoint subscribed to its type.
The event exists whether or not any delivery succeeded, so GET /v1/events answers “did it
happen” and an endpoint’s
delivery ledger answers “did my receiver get it”.
Retries, signatures and redelivery are on the Webhooks page.
Retention
| Record | Kept for |
|---|---|
| Events | 90 days. After that the list does not reach them, GET /v1/events/{id} is a 404 and the stream refuses the id as a cursor. |
| Webhook deliveries | 30 days, so a delivery row ages out while its event is still readable. |
| Audit log | 400 days. |
| Invoices, credit notes, payments, refunds | Never deleted. |
Events are a recovery and debugging surface, not an archive. If you need history beyond 90 days, persist what you care about when it arrives, or take regular exports.
Audit log
The audit log records who changed what. Every change made through the API or the console writes an entry. Changes that happen on their own, such as a renewal settling or a trial ending, are events rather than audit entries. Entries are never edited, with one exception: purging a customer redacts that customer’s personal data in entries already written (see Deleting versus purging). The only thing that removes an entry is the retention sweep, 400 days after it was written.
Some actions exist only here, because they are about access rather than billing state: API keys
created and revoked, portal sessions created, VAT numbers validated, tax rates changed, invoices
emailed, customers purged, data exported. Three high-volume entries are sampled to at most one per
minute per key: api_key.used, api_key.insufficient_scope and rate_limit.exceeded.
curl "https://api.billkit.eu/v1/audit_logs?action=product.created&limit=1" \
-H "Authorization: Bearer $BILLKIT_API_KEY"{
"object": "list",
"has_more": false,
"data": [
{
"id": "al_0f5c3a7e2b8d4e619a4fc1d27e8b6a90",
"object": "audit_log",
"created": 1789344500,
"livemode": false,
"actor_type": "api_key",
"actor_id": "ak_6e2b9d147c3a4f08b5e192a4d0c7f318",
"action": "product.created",
"resource_type": "product",
"resource_id": "prod_2Nk7Lv4xTc8mQw3Bhs",
"metadata": {
"name": "Pro plan"
},
"ip_address": "203.0.113.7",
"user_agent": "curl/8.7.1"
}
]
}actor_id is the id of whatever actor_type names:
| actor_type | Who |
|---|---|
| api_key | A request made with one of your API keys. actor_id is the key’s id, as GET /v1/api_keys shows it. |
| operator | A person signed in to the console. |
| portal_session / checkout_session | A customer’s billing-portal or embedded-checkout session. Seen on rate_limit.exceeded entries. |
| one_shot_payment | A buyer paying an embedded one-off payment. actor_id is the one-off payment’s id. Seen when the buyer confirms a payment, and on rate_limit.exceeded entries. |
| mollie_webhook | A chargeback opened or closed by the payment provider. |
| system / reaper | BillKit acting on its own, for example deriving a customer’s country or sending a dunning notice. |
ip_address and user_agent are the caller’s, when there was a caller. metadata holds whatever
detail the action records: the old and new fingerprints on a secret rotation, for example.
GET /v1/audit_logs filters on action, resource_type, resource_id and actor_id, all
matched exactly, and paginates like every other list. GET /v1/audit_logs/{id} fetches one
entry by its al_ id. Entries are per mode, like everything else, with one exception: actions on
the account as a whole (team members and invitations, sign-in step-up, custom domains, portal
branding, your billing details) are filed as live and appear in both modes’ lists, because they
change the same account whichever mode you were looking at. Both routes need the
audit_logs:read scope.
Exporting everything
GET /v1/tenant/export returns everything in your account as one JSON document: your catalogue
(products, prices, coupons, tax rates), customers, subscriptions, payments and one-off payments,
refunds, invoices with their line items, credit notes, disputes, usage records and events. Every
record has the same shape the matching GET returns. Use it for a backup, a migration, or a
GDPR data-portability request.
It needs the data:export scope. A full-access key has it; a restricted key has it only if you
granted it, which lets you give a read-only key everything except the bulk download.
curl https://api.billkit.eu/v1/tenant/export \
-H "Authorization: Bearer $BILLKIT_API_KEY"{
"object": "tenant_export",
"billkit_export_version": 2,
"tenant_id": "3d9a6f21-84c7-4b1e-a0d5-7f2c9e18b643",
"livemode": false,
"generated_at": 1789344500,
"products": [
{
"id": "prod_2Nk7Lv4xTc8mQw3Bhs",
"object": "product",
"name": "Pro plan"
}
],
"prices": [],
"coupons": [],
"tax_rates": [],
"customers": [],
"subscriptions": [],
"payments": [],
"one_shot_payments": [],
"refunds": [],
"invoices": [],
"credit_notes": [],
"disputes": [],
"usage_records": [],
"events": []
}A few things to handle:
- Write it to a file (
-o billkit-export.jsonwith curl). The body streams and can be large. - Check that it parses. The status is
200as soon as the first byte is sent, so a failure partway through cannot change it. A body that is not complete JSON is an incomplete export: discard it and request again. - One mode per call. The export covers the mode of the key you call with. Run it with a test key and a live key to get both.
- One moment. Every section is read from the same point in time, so a payment never refers to a customer the file does not contain.
- Events follow retention, so the
eventssection reaches back 90 days.
The export changes nothing, counts as one request against your read limit, and is recorded in
the audit log as tenant.data_exported.
BillKit