Node.js
TypeScript-first server client for every resource, with typed responses, typed webhook events, auto-pagination and per-request options.
Install
Supported on Node.js 22 and 24. It uses only fetch and crypto.subtle, so other runtimes may work, but nothing tests them. ESM and CJS builds ship together, with types.
The package follows semantic versioning from 1.0.0: nothing in a 1.x release breaks code written against an earlier 1.x. Coming from 0.x, read Upgrading from 0.x.
Initialise
The constructor takes an options object, and a key it does not know is refused with a TypeError (timeoutMs, the 0.x name, says to use timeout). Leave apiKey out and it reads BILLKIT_API_KEY from the environment; with neither, the constructor throws. An explicit empty apiKey throws as well, rather than falling back to the environment. timeout is milliseconds per attempt and defaults to 30 seconds; maxRetries is the number of retries after the first attempt and defaults to 3. Both are defaults that any one call can override. In serverless runtimes build the client at module scope so connections survive between invocations.
import { BillKit } from "@billkit-eu/sdk";
export const client = new BillKit({
apiKey: process.env.BILLKIT_API_KEY,
baseUrl: "https://api.billkit.eu",
timeout: 30_000,
maxRetries: 3,
});You can also pass fetch to supply your own implementation, for a proxy or a test double, and retryPolicy to tune the backoff itself.
Making calls
One accessor per resource family, each mirroring the routes under /v1/<resource>. Every method takes the object’s id first when it has one, then its parameters, then request options last. The calls the API takes no parameters for yet (cancel, revoke, sendEmail, retrieve on most resources and so on) still have the parameters slot, typed EmptyParams, so pass {} before the options: cancel(sub.id, {}, { idempotencyKey }). A parameter the API adds to one of them later is then an additive change.
| Accessor | Methods |
|---|---|
customers | create retrieve update delete list iter setVatNumber purge |
products, prices, taxRates | create retrieve update list iter |
coupons | create retrieve update validate list iter |
checkoutSessions | create retrieve |
oneShotPayments | create retrieve list iter |
subscriptions | retrieve list iter cancel reactivate pause resume previewUpdate update reauthorizePaymentMethod createUsageRecord listUsageRecords iterUsageRecords retrieveUsageSummary |
payments | retrieve retrieveProvider list iter |
refunds | create retrieve list iter |
disputes, events, auditLogs | retrieve list iter |
invoices | retrieve retrievePdf sendEmail void list iter |
creditNotes | retrieve retrievePdf list iter |
webhookEndpoints | create retrieve update delete rotateSecret list iter listEventTypes listDeliveries iterDeliveries retrieveDelivery redeliver |
billingPortalSessions | create revoke |
apiKeys | create retrieve revoke list iter |
testClocks | create retrieve advance delete list iter (test mode only) |
tenant | retrieveCapabilities retrieveBillingProfile setBillingProfile retrievePortalBranding setPortalBranding rotateProviderCredential export |
expand is accepted by list and iter on customers, products, subscriptions, payments, invoices and events, and by retrieve on products, subscriptions, payments and invoices. invoices.retrievePdf and creditNotes.retrievePdf resolve to an ArrayBuffer, and tenant.export to a ReadableStream. The live event stream has no method here; call that route over HTTP.
Responses
Every method resolves to a plain object typed from the API’s published OpenAPI document: Customer, Subscription, ListResponse<Invoice> and so on, all exported from the package. There are no classes and no runtime validation, so JSON.stringify and spreading behave exactly as they do on any object.
const customer = await client.customers.retrieve("cus_9XKp2vQ1");
console.log(customer.email, customer.created);A field the API adds after this SDK version is still on the object at runtime, and nothing throws on it. Enum-like fields are open: Subscription["status"] is "incomplete" | "trialing" | "active" | "past_due" | "canceled" widened with string, so your editor offers the known values and a value added later is not a type error. Handle the values you know and treat the rest as “something newer”, never as a failure. Each method also takes a type parameter, retrieve<MyCustomer>(id), for the rare caller who wants to narrow further; it is an unchecked assertion.
Request options
Every method takes one more argument, last, after its parameters: idempotencyKey, timeout (milliseconds), maxRetries (0 sends one attempt), headers and an AbortSignal as signal. They apply to that call only.
const customer = await client.customers.create(
{ email: "ada@example.com" },
{ idempotencyKey: "signup-ada", timeout: 10_000, maxRetries: 1 },
);An aborted call is never retried and rejects with the signal’s reason, as fetch does. A key that is not a request option, or a request option left in the parameters (cancel(id, { timeout })), is refused with a TypeError before anything is sent. That includes a superset object, such as your own config with the options spread into it: pass only the option keys. A key whose value is undefined counts as absent, so { ...defaults, idempotencyKey: undefined } is fine.
headers adds request headers but cannot replace the ones the SDK owns: Authorization, Idempotency-Key, Content-Type and User-Agent, in any letter case, are refused, and so is a value that is not a string. To choose the idempotency key, use idempotencyKey.
Auto-pagination
Every resource with a list() has an iter() async iterator that walks the has_more and starting_after cursors for you. Paging is forward-only. iter() takes exactly what list() takes, carries every filter onto every page, and reads limit as the page size.
for await (const customer of client.customers.iter()) {
console.log(customer.id);
}
// Filter at the server and raise the page size to cut round-trips.
for await (const evt of client.events.iter({ type: ["subscription.created"], limit: 100 })) {
await handle(evt);
}Idempotency and retries
Connection errors, timeouts and every 5xx except 501 are retried with jittered exponential backoff. A 429 is retried only when its Retry-After is 30 seconds or less. The default policy makes four attempts in total, starting at 500 ms and doubling up to 8 seconds between them.
Every mutating call carries an Idempotency-Key, generated once per call and reused on every retry, so a retry replays the original response instead of charging twice. Pass idempotencyKey in the request options to make that hold across process restarts.
409 idempotency_in_progress is retried too, reusing the same key. It means an earlier request with that key is
still in flight, and retrying with a fresh key is what turns one charge into two. Every other 409 fails immediately.
Errors
Every error from a request extends BillKitError; an argument mistake caught before anything is sent is a TypeError. An HTTP error response is an APIError, and the class is chosen by HTTP status, not by the envelope’s type. It carries statusCode, requestId, headers, rawBody and the envelope’s type, code, param and reason (the finer-grained cause some codes have, such as renewal_processing on a 409 operation_pending). RateLimitError adds retryAfter in seconds.
import { APIError, NotFoundError, RateLimitError } from "@billkit-eu/sdk";
try {
await client.customers.retrieve("cus_gone");
} catch (err) {
if (err instanceof NotFoundError) return null;
if (err instanceof RateLimitError) return retryIn(err.retryAfter);
if (err instanceof APIError) throw new Error(`BillKit ${err.statusCode}: ${err.message}`);
throw err;
}400 is InvalidRequestError, 401 AuthenticationError, 402 PaymentError (a charge or refund the call needed was refused, payment_refused, or your account is suspended, tenant_suspended), 403 PermissionDeniedError, 404 NotFoundError, 409 ConflictError, 429 RateLimitError, any other 4xx InvalidRequestError, and 5xx ServerError. A 2xx whose body is not JSON is a plain APIError. No answer at all is APIConnectionError, and APITimeoutError when it ran out of time, with the underlying failure on cause. type, code and reason are open strings: compare against the values you handle. instanceof works across the ESM and CommonJS builds, so an application that loads both still catches the errors either one throws.
Webhooks
verifyWebhookSignature returns WebhookEvent | UnknownWebhookEvent. WebhookEvent is the union of the event types this SDK version knows, discriminated on type; UnknownWebhookEvent is a delivery of a type it does not know, with data: unknown, because a newer API can send one. Narrow with isKnownEventType(event) first, then on type, and data is typed as the object that event carries. previous_attributes is always present, null when nothing changed.
import { isKnownEventType, verifyWebhookSignature, WebhookVerificationError } from "@billkit-eu/sdk";
try {
const event = await verifyWebhookSignature({
payload: rawBody, // string or Uint8Array, never the parsed object
signatureHeader: request.headers.get("BillKit-Signature"),
secret: process.env.BILLKIT_WEBHOOK_SECRET!,
});
// A type newer than this SDK: acknowledge it, or BillKit retries it.
if (!isKnownEventType(event)) return new Response(null, { status: 200 });
if (event.type === "invoice.paid") console.log(event.data.number); // data: Invoice
await handle(event);
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 });
throw err;
}The signature is compared in constant time, and a timestamp more than five minutes from now is rejected to stop replays. Pass toleranceSeconds to change the window. The header can carry several v1 signatures while you rotate a secret; any one matching is enough.
New event types are additive within 1.x: a minor release can add one to WebhookEvent. An exhaustive check that assigns the leftover event.type to never is therefore unsupported, because the release that names a new type stops it compiling. Handle the types you care about and acknowledge the rest.
Erasing a customer
customers.purge erases a customer’s personal data for good. It takes an explicit confirmation, and without one it is refused before anything is sent. It resolves to the same marker as delete(), plus purged: true, and the customer is gone from the API afterwards: retrieving it is a NotFoundError.
const leaving = await client.customers.create({ email: "leaving@example.com" });
const purged = await client.customers.purge(leaving.id, { confirmed: true });
console.log(purged); // { id: "cus_...", object: "customer", deleted: true, purged: true }Exporting your account
tenant.export() resolves to a ReadableStream of the account as one JSON document. Write it to a file rather than holding it in memory.
import { createWriteStream } from "node:fs";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
const body = await client.tenant.export();
await pipeline(Readable.fromWeb(body), createWriteStream("billkit-export.json"));The per-attempt timeout covers the response headers only, and there is no timeout once they have arrived: the body is read at your pace, so pass a signal (such as AbortSignal.timeout(600_000)) to bound it. A connection lost partway through errors the stream with an APIConnectionError, carrying the runtime’s own error on cause.
Logging
The SDK is silent by default and picks no destination for its output. Pass a logger to opt in.
const client = new BillKit({ apiKey, logger: console });You get one debug per attempt and per response, and one warn per retry. Only the method, the URL without its query string, the attempt, status, duration and request id are logged; your API key, request and response bodies and the query string never are. Anything with debug(message, context) and warn(message, context) works, console included. A logger that takes the context first needs a two-line wrapper.
Upgrading from 0.x
1.0 is a deliberate break, and every change is listed in the package’s CHANGELOG.md with before and after code. In short:
- Responses are typed from the API spec. Remove the generic you passed to every call; keep it only to narrow further.
idempotencyKeymoved out of the parameters into the request options, the last argument:create(params, { idempotencyKey }).- Lifecycle calls take their parameters second and their options third:
cancel(id, {}, { idempotencyKey }), where 0.x tookcancel(id, { idempotencyKey }). - Client options:
timeoutMsistimeout, andmaxRetriesis new. - Errors:
ResourceMissingErrorisNotFoundError,PermissionErrorisPermissionDeniedError, every HTTP error class extendsAPIError, and timeouts areAPITimeoutError.WebhookVerificationErrorextendsBillKitError. iter()takes the same parameters aslist();pageSizeislimit.customers.purge(id)needs{ confirmed: true }, and resolves to a{ id, object, deleted, purged }marker rather than the customer.methodispayment_methodon checkout sessions and one-off payments,test_clockistest_clock_idon customers, and a dispute’sreversedisreversed_at.- List filters that take several values are arrays, sent comma-joined:
subscriptions.list({ status: ["active", "past_due"] }),events.list({ type: ["invoice.paid"] }). tenant.capabilities(),tenant.billingProfile()andtenant.portalBranding()areretrieveCapabilities(),retrieveBillingProfile()andretrievePortalBranding().tenant.export()resolves to aReadableStream, not anArrayBuffer.coupons.createtakespercent_offoramount_off_centsinstead ofdiscount_typeplusdiscount_value, and a price tier takesunit_amount_centsandflat_amount_cents.verifyWebhookSignaturereturnsWebhookEvent | UnknownWebhookEvent(narrow withisKnownEventType), and no longer acceptsnowMs.- Removed:
webhookEndpoints.getDelivery(useretrieveDelivery),paginate,PaginateOptions,ListParamsandListResponseEnvelope(useListResponse),ExpandOptions(expandis in each method’s parameters) andIdempotencyOptions(useRequestOptions). - A 2xx response whose body is not JSON now throws an
APIErrorinstead of resolving toundefined. - Node 22 or newer.
BillKit