Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

SDK for TypeScript

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

  • One runtime dependency: @bookrail/webhook-signature, which itself has none.
  • 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.
Terminal window
npm install @bookrail/node
import Bookrail from '@bookrail/node';
const bookrail = new Bookrail(process.env.BOOKRAIL_SECRET_KEY!);
const { slots } = await bookrail.availability.list({
service_id: 'svc_0198f0c2a1b47e2e9a1c0f4d5e6a7b8c',
from: '2026-09-08T00:00:00+02:00',
to: '2026-09-15T00:00:00+02:00',
timezone: 'Europe/Rome',
});
const booking = await bookrail.bookings.create(
{
service_id: 'svc_0198f0c2a1b47e2e9a1c0f4d5e6a7b8c',
start: slots[0].start,
customer: { email: 'anna@example.com', name: 'Anna' },
},
{ idempotencyKey: order.id },
);
await bookrail.bookings.confirm(booking.id);

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

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), 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.

A CommonJS build, opt-in telemetry, structured logging, and @bookrail/browser with publishable keys. There are also no payments: payment.mode other than none is a 400 not_yet_supported, and a refund is an expectation the API computes rather than money that moves.

  • Quickstart, timed against the production API.
  • Concepts: the data model, with figures.
  • API reference: 67 operations, 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 issued by hand (hello@bookrail.dev), 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.