BillKit/DocsConsole
Build & operate

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.

Updated Oct 14, 2026·OpenAPI v1.0.0

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.

FieldNotes
idstringAn evt_… id. Deduplicate on it.
typestringOne of the types in the event catalogue.
createdintegerUnix seconds.
livemodebooleanTest and live events are separate. A key only ever sees its own mode.
dataobjectThe 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_attributesobjectOn an update, the fields that changed with their values before the change. null when nothing changed. Always present.
customerobjectnull 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.

shell
curl "https://api.billkit.eu/v1/events?type=subscription.created&limit=1&expand=customer" \
-H "Authorization: Bearer $BILLKIT_API_KEY"
Response · 200 OK (data abridged)
{
"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.

shell
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.

shell
curl -N "https://api.billkit.eu/v1/events/stream?type=invoice.paid,payment.failed&starting_after=evt_Ck7Rn2vZ9xQ4mTpLbw" \
-H "Authorization: Bearer $BILLKIT_API_KEY"
Stream
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:

FrameMeaning
messageOne 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-aliveA comment line, sent after 15 seconds without events so proxies do not close the connection. Ignore it.
timeoutThe 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.
unauthorizedThe 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

RecordKept for
Events90 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 deliveries30 days, so a delivery row ages out while its event is still readable.
Audit log400 days.
Invoices, credit notes, payments, refundsNever 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.

shell
curl "https://api.billkit.eu/v1/audit_logs?action=product.created&limit=1" \
-H "Authorization: Bearer $BILLKIT_API_KEY"
Response · 200 OK
{
"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_typeWho
api_keyA request made with one of your API keys. actor_id is the key’s id, as GET /v1/api_keys shows it.
operatorA person signed in to the console.
portal_session / checkout_sessionA customer’s billing-portal or embedded-checkout session. Seen on rate_limit.exceeded entries.
one_shot_paymentA 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_webhookA chargeback opened or closed by the payment provider.
system / reaperBillKit 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.

shell
curl https://api.billkit.eu/v1/tenant/export \
-H "Authorization: Bearer $BILLKIT_API_KEY"
Response · 200 OK (records abridged)
{
"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.json with curl). The body streams and can be large.
  • Check that it parses. The status is 200 as 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 events section 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.