Skip to content

SDK for TypeScript

The Bookrail SDK for TypeScript and JavaScript: call the Bookrail booking API from your server, with types generated from the OpenAPI specification of the API and one hand-written layer on top: retries with backoff, automatic idempotency on every POST, cursor pagination as an async iterator, typed errors, and webhook signature verification.

Use it in the server side code of an app that takes bookings: an API route, a server action, a backend, a worker, a serverless or edge function. It is how the app asks for availability, creates and cancels bookings, and verifies the webhooks Bookrail sends when something changes.

Do not use it:

  • in a browser or a mobile app: it needs a secret key, and a secret key in client code is a public key. There is no browser SDK and there are no publishable keys yet, so the front end calls your server and your server calls Bookrail;
  • to set up the model of the business (locations, resources, schedules, services, policies): that is bookrail.config.ts and npx bookrail push, from the bookrail CLI, or the @bookrail/mcp server for an agent;
  • for one person’s own calendar or a meeting link: Bookrail has no hosted booking page, no calendar sync and no UI components.
Terminal window
npm install @bookrail/node
import Bookrail from '@bookrail/node';
const bookrail = new Bookrail(process.env.BOOKRAIL_SECRET_KEY!); // on the server only
const { next_available } = await bookrail.availability.next({ service_id: 'svc_...' });
if (next_available === null) throw new Error('Nothing bookable in the search window.');
const booking = await bookrail.bookings.create(
{ service_id: 'svc_...', start: next_available, customer: { email: 'ada@example.com' } },
{ idempotencyKey: 'order-4711' },
);
console.log(booking.id, booking.status);

svc_... is the id of a service of your project: npx bookrail services list prints them. order-4711 stands for your own order id: a retry with the same key replays the first answer instead of booking twice. The key comes from npx bookrail signup, which a person runs once.

  • ESM only. There is no CommonJS build. require('@bookrail/node') will not work; use import, or await import() from CommonJS.
  • Node 20.10 or newer, Deno, Bun, Cloudflare Workers, Vercel edge. Only webhooks.constructEvent needs node:crypto; everything else runs on the global fetch and the Web Crypto API.
  • Its only runtime dependency is @bookrail/webhook-signature, which itself has none.
  • The secret key stays on the server. Read it from BOOKRAIL_SECRET_KEY in the server environment. Never in browser or mobile code, never in a variable the bundler exposes to the client, never committed, never logged.
  • Test unless the person says live. Write and run everything with a sk_test_ key. A sk_live_ key books for real, and only the person decides when to use one.
  • A person signs up, never the agent. Ask them to run npx bookrail signup in their own terminal: it needs their mailbox and their acceptance of the Terms.
  • Never build an instant. Pass start exactly as an availability answer returned it.
  • Verify every webhook with bookrail.webhooks.constructEvent on the raw body, and keep the signing secret in the server environment.
  • When a call fails, read error.fix. It says what to do next.

Data is snake_case (service_id, duration_minutes), because that is what the API sends and receives. A renaming layer would be one more place to get it wrong, would make the API reference unusable next to the SDK, and would have to be reinvented in every other language. Methods and namespaces are camelCase (bookrail.resourceGroups.list()), because those are ours.

Every instant is ISO 8601 with an explicit offset in, and UTC out. A bare date (2026-09-11) is not an instant: midnight is not the same moment in every time zone, and the API refuses it.

const bookrail = new Bookrail(process.env.BOOKRAIL_SECRET_KEY!, {
baseUrl: 'https://api.bookrail.dev', // default
apiVersion: '2026-09-01', // default: the version the specification declares
timeoutMs: 30_000, // per attempt
maxRetries: 2, // extra attempts after the first
fetch: myFetch, // injectable
actor: 'sdk', // default; pass `undefined` to send no Bookrail-Actor
});

The key must start with sk_test_ or sk_live_; anything else throws synchronously at construction. bookrail.environment is 'test' or 'live', decided by that prefix and by nothing else. Publishable pk_ keys belong to the browser SDK, which does not exist yet.

actor is sent as Bookrail-Actor and recorded as actor.via on every event the request writes, so a booking made by this SDK can be told from one made by the CLI or the dashboard at equal API key.

await bookrail.bookings.create(params, {
idempotencyKey: 'order-4711',
timeoutMs: 5_000,
maxRetries: 0,
expand: ['customer', 'allocations.resource'],
signal: controller.signal,
headers: { 'X-Trace-Id': traceId },
});

Every POST carries an Idempotency-Key. If you do not pass one, the SDK generates a UUID and sends the same one on every retry of that call. That is what makes retrying a POST safe: the API replays the first response instead of booking again.

Retried: 429, every 5xx, connection failures, timeouts, and the two 409 codes that mean “come back in a moment” (idempotency_key_in_progress, serialization_failure). Never retried: any other 4xx, and an abort through your own AbortSignal.

The backoff is 0.5 s, 1 s, 2 s, 4 s, 8 s, capped, with ±25 % jitter. A Retry-After header wins, in seconds or as an HTTP date; longer than a minute and the call fails instead of blocking.

const { data, response } = await bookrail.bookings.create(params).withResponse();
response.status; // 201
response.requestId; // 'req_…', quote it to support
response.idempotentReplayed; // true when the API replayed a stored answer
response.retries; // how many extra attempts it took
const page = await bookrail.bookings.list({ status: 'confirmed', limit: 50 });
page.data; // this page
page.has_more;
await page.nextPage();
// or every page, following the cursor for you
for await (const booking of bookrail.bookings.list({ status: 'confirmed' })) {
console.log(booking.id);
}

limit is per page, not a total. The cursor is the id of the last object of a page; filters and expand travel with it.

import { BookrailConflictError, BookrailError } from '@bookrail/node';
try {
await bookrail.bookings.create(params);
} catch (error) {
if (error instanceof BookrailConflictError) {
// the slot went while you were deciding
} else if (error instanceof BookrailError) {
error.type; // 'conflict' | 'invalid_request' | 'not_found' | …
error.code; // 'slot_unavailable'
error.param; // the field it is about, when there is one
error.fix; // what to do next, when the API had one thing to say
error.docUrl;
error.requestId;
error.status;
error.headers;
}
}

One class per family: BookrailInvalidRequestError, BookrailAuthenticationError, BookrailPermissionError, BookrailNotFoundError, BookrailConflictError, BookrailRateLimitError, BookrailPolicyViolationError, BookrailPaymentRequiredError, BookrailInternalError, plus BookrailConnectionError (network, timeout, abort) and BookrailSignatureVerificationError.

rate_limited. Every API key has a ceiling: 20 requests a second with bursts of 40 on a sk_test_ key, 100 a second with bursts of 500 on a sk_live_ one. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (whole seconds), so you can pace yourself before you run out, and a refusal is a 429 rate_limited with Retry-After. This client already waits for you: a 429 is retried after the Retry-After the server sent, up to maxRetries times, so you only see BookrailRateLimitError when the retries are spent or when you asked for none. Its fix says what to do, and error.headers['retry-after'] says when.

import Bookrail, { BookrailSignatureVerificationError } from '@bookrail/node';
app.post('/hooks/bookrail', express.raw({ type: 'application/json' }), (request, response) => {
let event;
try {
event = bookrail.webhooks.constructEvent(
request.body, // the RAW bytes, never a re-encoded object
request.headers['bookrail-signature'],
process.env.BOOKRAIL_WEBHOOK_SECRET!,
);
} catch (error) {
if (error instanceof BookrailSignatureVerificationError) return response.sendStatus(400);
throw error;
}
if (event.type === 'booking.created') { /* … */ }
response.sendStatus(200);
});

Pass the body exactly as it arrived. Two JSON encoders disagree about key order and whitespace, so a body that was parsed and re-encoded will not verify. Deduplicate on Bookrail-Event-Id: delivery is at-least-once.

constructEvent is the only part of this package that reaches node:crypto, through @bookrail/webhook-signature. Everything else runs where Node built-ins do not exist.

project, availability, locations, resources (with resources.blocks), resourceGroups, schedules (with schedules.exceptions), services, policies, customers, holds, bookings, events, webhooks (with webhooks.deliveries), stripe, and openapi. One method per operation of the API: create, list, retrieve, update, del, plus the actions (bookings.confirm, bookings.noShow, resources.block, webhooks.test, …).

del, not delete: delete is legal as a method name in JavaScript but reads as the operator at a glance, and del is what the Node SDKs of this shape have called it for a decade.

bookrail.stripe.connect() returns a link a person opens to authorise their own Stripe account; nothing is connected until they do. bookrail.stripe.retrieve() says whether one is, which account it is, and the platform publishable key to initialise Stripe.js with, together with { stripeAccount: account_id }. bookrail.stripe.disconnect() revokes it. No method takes a Stripe key, because Bookrail never receives one.

const booking = await bookrail.bookings.create({
service_id: 'svc_...',
start: '2026-10-05T09:00:00+02:00',
payment: { mode: 'deposit' }, // 'none' | 'deposit' | 'full'
});
// booking.status === 'pending' and booking.payment_intent carries what a front end needs:
// loadStripe(publishable_key, { stripeAccount }) and then the client_secret.
const payment = await bookrail.payments.retrieve(booking.payment_intent!.payment_id);
for await (const row of bookrail.payments.list({ booking_id: booking.id })) { /* ... */ }

The client_secret is returned once and is stored nowhere: an idempotent replay answers the same booking with client_secret: null, and payments.retrieve reads it back from Stripe. The booking is cancelled automatically at payment_expires_at if nobody pays.

payments has two methods and no third: a payment is created by bookings.create, and a refund by bookings.cancel, which follows the policy the customer agreed to.

A CommonJS build, opt-in telemetry, structured logging, and @bookrail/browser with publishable keys. In payments: a deferred balance, saved cards, charging a no-show, asking for a refund through the API rather than through a cancellation, and rescheduling a booking that has money on it (422 reschedule_not_supported).

  • Quickstart, timed against the production API.
  • Concepts: the data model, with figures.
  • API reference: every operation, generated from the executable contract, packages/api/openapi/openapi.json, which this package’s types are generated from too.
  • Idempotency and Webhooks.

Early access. The API is live at https://api.bookrail.dev, keys are self service (npx bookrail signup, or bookrail.dev/signup, hands you a test key and a live key on the free plan, and bookrail.dev/dashboard makes and revokes more), and this package is on npm as @bookrail/node, Apache 2.0, with its source in github.com/bookrail-dev/bookrail under packages/sdk-node.

Apache-2.0.