BillKit/DocsConsole
SDK

Node.js

TypeScript-first server client for every resource, with typed responses, typed webhook events, auto-pagination and per-request options.

v1.1.1Node 22+OfficialSource

Install

npm install @billkit-eu/sdk

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.

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

AccessorMethods
customerscreate retrieve update delete list iter setVatNumber purge
products, prices, taxRatescreate retrieve update list iter
couponscreate retrieve update validate list iter
checkoutSessionscreate retrieve
oneShotPaymentscreate retrieve list iter
subscriptionsretrieve list iter cancel reactivate pause resume previewUpdate update reauthorizePaymentMethod createUsageRecord listUsageRecords iterUsageRecords retrieveUsageSummary
paymentsretrieve retrieveProvider list iter
refundscreate retrieve list iter
disputes, events, auditLogsretrieve list iter
invoicesretrieve retrievePdf sendEmail void list iter
creditNotesretrieve retrievePdf list iter
webhookEndpointscreate retrieve update delete rotateSecret list iter listEventTypes listDeliveries iterDeliveries retrieveDelivery redeliver
billingPortalSessionscreate revoke
apiKeyscreate retrieve revoke list iter
testClockscreate retrieve advance delete list iter (test mode only)
tenantretrieveCapabilities 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.

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

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

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

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

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

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

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

typescript
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.
  • idempotencyKey moved 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 took cancel(id, { idempotencyKey }).
  • Client options: timeoutMs is timeout, and maxRetries is new.
  • Errors: ResourceMissingError is NotFoundError, PermissionError is PermissionDeniedError, every HTTP error class extends APIError, and timeouts are APITimeoutError. WebhookVerificationError extends BillKitError.
  • iter() takes the same parameters as list(); pageSize is limit.
  • customers.purge(id) needs { confirmed: true }, and resolves to a { id, object, deleted, purged } marker rather than the customer.
  • method is payment_method on checkout sessions and one-off payments, test_clock is test_clock_id on customers, and a dispute’s reversed is reversed_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() and tenant.portalBranding() are retrieveCapabilities(), retrieveBillingProfile() and retrievePortalBranding().
  • tenant.export() resolves to a ReadableStream, not an ArrayBuffer.
  • coupons.create takes percent_off or amount_off_cents instead of discount_type plus discount_value, and a price tier takes unit_amount_cents and flat_amount_cents.
  • verifyWebhookSignature returns WebhookEvent | UnknownWebhookEvent (narrow with isKnownEventType), and no longer accepts nowMs.
  • Removed: webhookEndpoints.getDelivery (use retrieveDelivery), paginate, PaginateOptions, ListParams and ListResponseEnvelope (use ListResponse), ExpandOptions (expand is in each method’s parameters) and IdempotencyOptions (use RequestOptions).
  • A 2xx response whose body is not JSON now throws an APIError instead of resolving to undefined.
  • Node 22 or newer.