# Bookrail documentation > Booking infrastructure for developers: availability, resources, holds, bookings, policies and webhooks behind one API. Capacity is enforced by Postgres, not by application code. --- # Documentation Source: https://bookrail.dev/docs/ Bookrail is booking infrastructure: availability, resources, holds, bookings, policies and the events they emit, behind one HTTP API. It is the layer under a booking product, not the product. ## Where to start - **[Quickstart](/docs/quickstart/)**: from a key to a confirmed booking, with the CLI, the SDK or plain HTTP. Every command on it was run against the production API and timed. - **[Concepts](/docs/concepts/)**: the whole model in one page, with figures. The field by field list is the **[Entity reference](/docs/entities/)**. - **[Configuration](/docs/configuration/)**: `bookrail.config.ts`, the booking model as code. - **[The edge cases of booking](/docs/edge-cases/)**: sixteen things that go wrong in booking systems, what happens here, and the test that proves each one. - **[API](/docs/api/)**, the **[API reference](/docs/api/reference/)** and the **[SDK](/docs/sdk/)**: the short form, every operation generated from the OpenAPI document, and the TypeScript client. - **Guides**: [time zones](/docs/guides/time-zones/), [webhooks](/docs/guides/webhooks/), [idempotency](/docs/guides/idempotency/), [policies](/docs/guides/policies/) and [coding agents](/docs/guides/agents/). - **[Errors](/docs/errors/)**: every code, what it means, and what to do about it. - **[For AI agents](/docs/for-ai-agents/)**: the machine readable surface, in one page. ## What exists today The engine, the API, the CLI, the MCP server and the TypeScript SDK are written and tested against real Postgres. What does not exist yet, and is not documented as if it did: - **No way to get a key.** `api.bookrail.dev` is up and is the address in the `servers` block of the OpenAPI document, but there is no dashboard and no sign-up: keys are issued by hand. Ask for one on the early access page. - **No payments.** `payment.mode` other than `none` is refused with `not_yet_supported`, and `amount_due` is always `0`. - **No rate limiting**, and no scope enforcement: API key scopes are stored but not checked. ## Conventions Every instant on the way in carries an explicit offset; every instant on the way out is UTC. Identifiers are prefixed, and the prefix says what the object is. Amounts are integers in the minor unit of their currency. Lists are cursored, never offset, and never carry a total. Every `POST` accepts an `Idempotency-Key`, and the same key within 24 hours replays the same response. --- # Quickstart Source: https://bookrail.dev/docs/quickstart/ Every command and every response on this page was run against `https://api.bookrail.dev` on 8 September 2026, from a clean project, and copied out of the terminal. Nothing here is typed by hand. **The whole walk took 21 seconds of machine time**: 18 seconds for the CLI section and 3 seconds for the SDK one. The timings are at the bottom, step by step. What that number does not include is the part nobody can measure for you: reading this page, and the email that gets you a key. Budget ten minutes for the first run and one minute for every one after it. ## What exists today, and what does not Bookrail is in early access, and the honest version of that is a short list. - **The API is live** at `https://api.bookrail.dev`. It is the same code the tests run against. - **A test key comes from a person.** There is no sign up and no dashboard yet, so write to [hello@bookrail.dev](mailto:hello@bookrail.dev?subject=Bookrail%20early%20access) and say what you are building. Keys are `sk_test_...`; the live environment is a separate key. - **The packages are on npm.** `bookrail`, `@bookrail/node`, `@bookrail/mcp` and `@bookrail/webhook-signature` are published, Apache 2.0 ([Open source](/docs/open-source/) says what is open and what is not), so the `npx` and `npm install` lines below run exactly as they are written. The section that needs nothing installed at all is [plain HTTP](#3-with-plain-http-no-package-needed), because the API is up and it answers `curl`. - **There are no payments.** `payment.mode` other than `none` is a `400 not_yet_supported`. A policy can describe a deposit and a refund, and the booking freezes them, but no money moves. See [Policies](/docs/guides/policies/). ## 1. With the CLI ### Store the key ```bash npx bookrail login ``` It asks for the key, checks it against the API, and writes it with mode 600 to `~/.config/bookrail/credentials.json`. `--token -` reads it from standard input instead, which is what a script should do. ``` Stored the test key for project Bookrail smoke (proj_01a07cb881bb73848d0745023ba7aa33) in ~/.config/bookrail/credentials.json (mode 600). Next steps - Run `bookrail whoami --json` to confirm. - Run `bookrail init --template ` to create a bookrail.config.ts. - Run `bookrail push --dry-run` to see what would be created. ``` Everything is the test environment until you type `--live`, and a `sk_live_` key used without `--live` is a hard error rather than a warning. ### Describe the club ```bash npx bookrail init --template padel ``` That writes `bookrail.config.ts`: one location on `Europe/Rome`, opening hours 08:00 to 23:00 every day, two courts of capacity 1, a group that holds both, a prepaid policy with a two tier refund, and a service that offers 60 and 90 minutes on a half hour grid. ```ts export default defineConfig({ project: 'padel', locations: [{ id: 'club', name: 'Club', timezone: 'Europe/Rome' }], schedules: { club_hours: { name: 'Club hours', timezone: 'Europe/Rome', rules: [{ days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'], from: '08:00', to: '23:00' }], }, }, resources: [ { id: 'court_1', name: 'Court 1', type: 'court', location: 'club', schedule: 'club_hours' }, { id: 'court_2', name: 'Court 2', type: 'court', location: 'club', schedule: 'club_hours' }, ], resourceGroups: { courts: { name: 'Courts', resources: ['court_1', 'court_2'], allocationStrategy: 'first_available' }, }, policies: { prepaid: { name: 'Prepaid', cancellation: [{ before: '12h', refundPercent: 100 }, { before: '0h', refundPercent: 0 }], holdDuration: '10m', autoComplete: true, }, }, services: [ { id: 'match', name: 'Match', durationOptions: [60, 90], slotInterval: 30, alignTo: 'hour', price: { amount: 3000, currency: 'EUR' }, policy: 'prepaid', bookingWindow: { minNoticeMinutes: 60, maxAdvanceDays: 14 }, requirements: [{ group: 'courts', quantity: 1 }], }, ], }); ``` The file the template writes also carries `deposit` and `paymentTiming` on the policy, which are stored and honoured as data and move no money: see [Policies](/docs/guides/policies/). Nine vertical templates ship with the CLI: `bookrail init --help` lists them, and `bookrail examples ` prints a fuller model without writing anything. ### See the plan, then apply it ```bash npx bookrail push --dry-run ``` ``` [test] plan (dry run, nothing applied): 7 to create action kind id name changes ------ -------------- ---------- ---------- ------- create location club Club create schedule club_hours Club hours create resource court_1 Court 1 create resource court_2 Court 2 create resource group courts Courts create policy prepaid Prepaid create service match Match Next steps - Run `bookrail push` to apply it. ``` ```bash npx bookrail push ``` The same table, with `applied` instead of `plan`. Objects are matched by `metadata.config_id`, never by name, so a second `push` updates rather than duplicates, and an object that has no `config_id` is reported as unmanaged and left alone. Deletions need `--yes`. ### Ask what is free ```bash npx bookrail availability \ --service svc_01a08041186277d699e4cf0ac77eec54 \ --from 2026-09-14T00:00:00+02:00 \ --to 2026-09-15T00:00:00+02:00 ``` ``` [test] 57 slot(s) for svc_01a08041186277d699e4cf0ac77eec54, times shown in Europe/Rome start (UTC) local end (UTC) min cap price resources ------------------------ ---------------- ------------------------ --- --- --------- ------------------------------------------------ 2026-09-14T06:00:00.000Z 2026-09-14 08:00 2026-09-14T07:00:00.000Z 60 1 30.00 EUR res_01a0804110a97175a637c6fec7692f85 | res_01... 2026-09-14T06:00:00.000Z 2026-09-14 08:00 2026-09-14T07:30:00.000Z 90 1 30.00 EUR res_01a0804110a97175a637c6fec7692f85 | res_01... 2026-09-14T06:30:00.000Z 2026-09-14 08:30 2026-09-14T07:30:00.000Z 60 1 30.00 EUR res_01a0804110a97175a637c6fec7692f85 | res_01... ``` 57 is not a round number, and it is not supposed to be: 08:00 to 23:00 on a half hour grid gives 29 starts for the 60 minute option (08:00 to 22:00) and 28 for the 90 minute one, whose last start is 21:30 because 22:00 plus 90 minutes would run past closing. Instants go in with an explicit offset and come out in UTC; `--tz` changes only the local column, never the grid. A bare date is refused, with the reason: ``` Error [parameter_invalid] --from must be an ISO 8601 instant with an explicit offset, got "2026-09-14". param: from Fix: Write it as `2026-09-08T07:00:00Z` or `2026-09-08T09:00:00+02:00`. A bare date is refused because midnight is not the same instant in every time zone. https://bookrail.dev/docs/errors#parameter_invalid ``` ### Book one ```bash npx bookrail bookings create \ --service svc_01a08041186277d699e4cf0ac77eec54 \ --start 2026-09-14T18:00:00+02:00 \ --duration 60 \ --customer-email ada@example.com \ --customer-name "Ada Lovelace" ``` ``` [test] booked bk_01a0804124a974cfb34faa100c8dc6cf · confirmed field value --------------- ------------------------------------ service svc_01a08041186277d699e4cf0ac77eec54 start (UTC) 2026-09-14T16:00:00.000Z local 2026-09-14 18:00 Europe/Rome end (UTC) 2026-09-14T17:00:00.000Z duration 60 min quantity 1 price 30.00 EUR customer cus_01a0804122dd778ebca84130540b4ee1 hold next transition complete at 2026-09-14T17:00:00.000Z allocation resource role units ------------------------------------- ------------------------------------ ---- ----- ball_01a080412534755d93e77dd5cc1dd2ce res_01a0804110a97175a637c6fec7692f85 1 ``` That is the whole loop. The customer was created inline from the email; the courts are capacity 1, so the second court is still free at that instant and the third attempt is not. Here is what the third attempt actually says: ``` Error [slot_unavailable] The requested slot is no longer available. 1 unit requested, 0 available. No resource can serve requirement 01a08041-1890-7221-ae47-285fdac0d66b (Court 2). param: start request id: req_4ed7ea90ffd795fd2f8231a5 Fix: The capacity is gone. Run `bookrail availability --service ... --explain` to see what took it. https://bookrail.dev/docs/errors#slot_unavailable ``` And `--explain` names who took it: ``` [test] 0 slot(s) for svc_01a08041186277d699e4cf0ac77eec54, times shown in Europe/Rome 2 instant(s) rejected: occupied 4 local instant code resource why ---------------- -------- ------------------------------------ --------------------------------------------------- 2026-09-15 08:00 occupied res_01a0804110a97175a637c6fec7692f85 Court 1 is already taken during the booking window. 2026-09-15 08:00 occupied res_01a08041128171fdb467a328b0c51390 Court 2 is already taken during the booking window. 2026-09-15 08:30 occupied res_01a0804110a97175a637c6fec7692f85 Court 1 is already taken during the booking window. 2026-09-15 08:30 occupied res_01a08041128171fdb467a328b0c51390 Court 2 is already taken during the booking window. ``` ### Take the events ```bash npx bookrail webhooks create \ --url https://example.com/hooks/bookrail \ --events booking.created --events booking.cancelled ``` ``` [test] created wh_01a0804128b974cba21b5c55d26fe2f1 -> https://example.com/hooks/bookrail subscribed to: booking.created, booking.cancelled signing secret: whsec_... This is the only time the secret is shown. It is stored encrypted and no endpoint will ever return it again. If you lose it, the only remedy is to create another endpoint. Put it in your receiver now (BOOKRAIL_WEBHOOK_SECRET) and verify every delivery against it. ``` Store the secret at that moment: it is shown once and encrypted at rest, and no endpoint returns it again. Then watch what arrives: ```bash npx bookrail webhooks listen --max 1 --duration 40 ``` ``` [test] no --url given, so nothing was registered: following the event log instead. The objects below are exactly what a delivery would have carried, byte for byte, but no endpoint was called and no signature was produced. To exercise a real delivery, expose a local port (ngrok, cloudflared, ...) and re-run with `--url --port `. [test] following the event log every 2s. Ctrl-C to stop. 2026-09-08T09:03:15.475Z booking.cancelled bk_01a0804124a974cfb34faa100c8dc6cf [test] stopped (max) after 1 event(s). Next steps - This mode registers nothing and verifies no signature. - Run `bookrail webhooks listen --url --port 4100` to receive real deliveries. - Resume exactly here: `bookrail events list --follow --starting-after evt_01a08041defd7583b1ea5f6e378e9e23`. ``` With a public URL (`--url https:// --port 4100`) the same command registers a temporary endpoint, receives the real POSTs, verifies every `Bookrail-Signature`, and deletes the endpoint on the way out. Without one it polls the event log, which carries the same objects and no signature, and says so. The CLI does not open a tunnel for you. [Webhooks](/docs/guides/webhooks/) has the rest: the ladder, replays, and the SSRF rule. ## 2. With the SDK The configuration above is code, and the CLI pushes it. The SDK is for the part your application does at runtime: ask, book, read back. ```bash npm install @bookrail/node ``` ```ts import Bookrail from '@bookrail/node'; const bookrail = new Bookrail(process.env.BOOKRAIL_SECRET_KEY!); const service = 'svc_01a08041186277d699e4cf0ac77eec54'; const { slots } = await bookrail.availability.list({ service_id: service, from: '2026-09-15T00:00:00+02:00', to: '2026-09-16T00:00:00+02:00', timezone: 'Europe/Rome', }); console.log(`${slots.length} slots, first at ${slots[0].start}`); const params = { service_id: service, start: slots[0].start, duration_minutes: slots[0].duration_minutes, customer: { email: 'ada@example.com', name: 'Ada Lovelace' }, }; const options = { idempotencyKey: 'quickstart-demo-1' }; const booking = await bookrail.bookings.create(params, options); console.log(`${booking.id} ${booking.status} ${booking.start} -> ${booking.end}`); // The same call again, with the same key: no second booking, and the API says so. const again = await bookrail.bookings.create(params, options).withResponse(); console.log(`retry -> ${again.data.id} replayed=${again.response.idempotentReplayed}`); ``` ``` 57 slots, first at 2026-09-15T06:00:00.000Z bk_01a080425290775faa2272a54296ccb2 confirmed 2026-09-15T06:00:00.000Z -> 2026-09-15T07:00:00.000Z retry -> bk_01a080425290775faa2272a54296ccb2 replayed=true ``` Three lines, one booking. That last line is the whole point of [idempotency](/docs/guides/idempotency/): the key is taken with a unique constraint in the database, not checked with a read, so a retry cannot become a second booking even when the two requests are in flight at the same moment. If you do not pass a key, the SDK generates one and sends the same one on every retry of that call. The full surface, one method per operation of the API, is the [SDK reference](/docs/sdk/). It is `packages/sdk-node/README.md`, published here and on npm from the same file. ## 3. With plain HTTP, no package needed This is the section that runs today, because it needs nothing but a key and `curl`. ```bash curl -s https://api.bookrail.dev/v1/availability \ -H "Authorization: Bearer $BOOKRAIL_SECRET_KEY" \ -H 'Content-Type: application/json' \ -d '{"service_id":"svc_01a08041186277d699e4cf0ac77eec54", "from":"2026-09-16T00:00:00+02:00", "to":"2026-09-17T00:00:00+02:00", "timezone":"Europe/Rome"}' ``` ```json { "object": "availability", "service_id": "svc_01a08041186277d699e4cf0ac77eec54", "timezone": "Europe/Rome", "granularity": "slots", "slots": [ { "object": "availability_slot", "start": "2026-09-16T06:00:00.000Z", "end": "2026-09-16T07:00:00.000Z", "duration_minutes": 60, "available_capacity": 1, "price": { "amount": 3000, "currency": "EUR" }, "resource_options": [ { "resources": [{ "resource_id": "res_01a0804110a9...", "role": null, "capacity_used": 1 }] }, { "resources": [{ "resource_id": "res_01a0804112817...", "role": null, "capacity_used": 1 }] } ] } ] } ``` (The two identifiers are shortened here for the page. The response carries them in full, and `resource_options` is the list of concrete combinations that can serve the slot: either court.) ```bash curl -s https://api.bookrail.dev/v1/bookings \ -H "Authorization: Bearer $BOOKRAIL_SECRET_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: quickstart-curl-1' \ -d '{"service_id":"svc_01a08041186277d699e4cf0ac77eec54", "start":"2026-09-16T06:00:00Z", "duration_minutes":60, "customer":{"email":"ada@example.com","name":"Ada Lovelace"}}' ``` The booking comes back with 40 required fields. The ones worth reading first: ```json { "id": "bk_01a08043651774e9aedfb770932801c9", "object": "booking", "status": "confirmed", "start": "2026-09-16T06:00:00.000Z", "end": "2026-09-16T07:00:00.000Z", "duration_minutes": 60, "timezone": "Europe/Rome", "price": { "amount": 3000, "currency": "EUR" }, "policy_snapshot": { "name": "Prepaid", "cancellation": [{ "before": "12h", "refund_percent": 100 }, { "before": "0h", "refund_percent": 0 }], "auto_complete": true, "hold_duration_seconds": 600 }, "next_transition": "complete", "next_transition_at": "2026-09-16T07:00:00.000Z", "allocations": [ { "object": "booking_allocation", "resource_id": "res_01a0804110a9...", "capacity_used": 1 } ], "environment": "test", "created_at": "2026-09-08T09:04:55.167Z" } ``` `policy_snapshot` is the policy as it stood at the moment of the sale, copied into the booking. Change the policy tomorrow and this booking still refunds by these tiers. That is [Policies](/docs/guides/policies/), and it is the reason the field is there rather than a `policy_id` alone. Send the same request again with the same `Idempotency-Key` and the response headers say what happened: ``` HTTP/2 201 bookrail-request-id: req_2b119f20e3bc04f555dacee3 bookrail-version: 2026-09-01 idempotent-replayed: true ``` ## How long it took Wall clock, one laptop on a home connection in Italy, against `api.bookrail.dev`, on 8 September 2026. Each number is one process, cold Node start included. | Step | Command | Time | | --- | --- | --- | | 1 | `bookrail login` | 0.80 s | | 2 | `bookrail whoami` | 0.58 s | | 3 | `bookrail init --template padel` | 0.10 s | | 4 | `bookrail push --dry-run` | 2.10 s | | 5 | `bookrail push` (7 objects created) | 5.66 s | | 6 | `bookrail availability` (one day, 57 slots) | 1.09 s | | 7 | `bookrail bookings create` | 1.51 s | | 8 | `bookrail webhooks create` | 0.71 s | | 9 | `bookrail webhooks listen` plus a cancellation | 5.34 s | | | **CLI section** | **17.9 s** | | | SDK section (availability, booking, replayed retry) | 2.6 s | | | HTTP section (availability, booking, replay) | 3.1 s | The one slow step is `push`, and it is slow because it creates seven objects in seven round trips. Nothing else is over two seconds. ## Where to go next - [Concepts](/docs/concepts/): the model behind the config file, with the figures. - [The edge cases of booking](/docs/edge-cases/): what goes wrong in booking systems, what this one does about each case, and the test that proves it. - [API reference](/docs/api/reference/): all 67 operations, generated from the specification the server serves. - [For AI agents](/docs/for-ai-agents/): the same loop, driven by a coding agent through the CLI or the MCP server. --- # Concepts Source: https://bookrail.dev/docs/concepts/ Ten objects. Everything a booking system does is made of them, and nothing in the model is special cased for a vertical: a padel court, a dentist and a boat rental are the same shape with different numbers. One page on purpose. A schedule means nothing without the resource it belongs to, and ten tabs would be a filing system rather than an explanation. The field by field list is separate: [Entity reference](/docs/entities/). ## The shape of it ``` Account your organisation └── Project one application, in one environment: test or live ├── Location a place, and the time zone that place lives on ├── Resource the thing that gets occupied, with a capacity ├── ResourceGroup interchangeable resources, and how to pick one ├── Schedule when a resource is open, on a local clock ├── Service what you sell, and what it needs to happen ├── Policy the rules of cancelling, moving and not turning up ├── Customer who booked ├── Hold a short lease on capacity while somebody decides ├── Booking capacity taken, rules frozen ├── Event the append only log of everything above └── Webhook where events go ``` A **project** is the boundary of everything else. Test and live are two projects in every way that matters: separate keys, separate rows, and a row of one is invisible to a key of the other. Row level security in Postgres is what enforces that, not a `WHERE` clause somebody might forget. ## Location: where the clock lives A location is a place with an IANA time zone. That is its real job. The time zone belongs to the thing being booked, not to the person booking it: a court in Rome is bookable at 18:00 Rome time whoever asks, and from Tokyo that is 01:00 the next day. Put the zone on the buyer and every schedule in the system becomes wrong twice a year. A virtual resource still has a location, with an explicit zone, because a video consultation still has an hour on somebody's clock. ## Resource: the thing that gets occupied A resource is what a booking consumes: a person, a room, a court, a vehicle, a seat, or a slot in a class. It has a `capacity`, an integer, and that number is the whole difference between "a court" and "a yoga class". - `capacity: 1` is a court, a chair, a car. The database refuses a second overlapping occupancy with an exclusion constraint, so a double booking is impossible even if the application asks for one. - `capacity: 15` is a class or a table of 15 seats. A trigger refuses the unit past the capacity, in SQL, on the same connection the application uses. Neither guarantee is application code, which is the point: a bug in the engine cannot produce an overbooking, only an error. ## Schedule: opening hours as rules and exceptions A schedule is a set of weekly rules written on a **local clock**, plus dated exceptions. Rules say "Monday to Friday, 09:00 to 18:00". Exceptions say "closed on 25 December" or "open 10:00 to 14:00 on this Sunday only". Rules are materialised into UTC one local day at a time, from the IANA database. That is why the night a clock changes has 23 or 25 hours and a `09:00` to `19:00` rule still lasts ten real hours on both, while a `01:00` to `04:00` rule lasts two hours in spring and four in autumn. The full order of operations, and what happens to a band that crosses midnight, is in [Time zones](/docs/guides/time-zones/). Alongside the schedule there are **blocks**: a resource is closed from here to there, for maintenance or holiday. A block is not a booking; it takes the whole capacity of the resource for its period, and it cannot be placed over something already sold.
One resource, one morning, as segments of time Three horizontal tracks over the hours 08:00 to 14:00. The first track, open, is a single band from 08:00 to 13:00. The second track, taken, holds a booking from 09:00 to 10:00, a hold from 11:00 to 11:30 and a block from 12:00 to 13:00. The third track, free, is what is left: 08:00 to 09:00, 10:00 to 11:00 and 11:30 to 12:00. 08:00 09:00 10:00 11:00 12:00 13:00 14:00 open schedule: every day 08:00 to 23:00, local taken bk_ 60 min hold block free 3 segments, before the service asks for a duration
Availability is subtraction, not search. The engine takes the open bands of a resource for one local day, removes every active occupancy and every block, and is left with a set of segments in a normal form. Only then does a service impose its duration, its grid and its buffers on them. The synthetic day above is a padel court on the template of the quickstart: a one hour booking in ink, a ten minute hold in the accent tint, and a maintenance block hatched.
## Service: what you sell, and what it needs A service is the thing a customer buys, and its most important field is not its price. It is `requirements`: the list of what has to be free at the same instant for the service to happen. A haircut needs one hairdresser. A dental appointment needs one dentist **and** one surgery room. A padel match needs one court out of the group of courts. Each requirement names a resource or a group, a quantity, and an optional role that ends up on the allocation. The rest of the service is the shape of the offer: `duration` or `duration_options` or a `duration_range`, a `slot_interval` and an `align_to` that make the grid, buffers before and after, a booking window (`min_notice_minutes`, `max_advance_days`), and a price.
A service with two requirements, and the resources that can serve them A service box on the left connects to two requirement boxes. The first requirement asks for one practitioner out of three, of which one is already taken. The second asks for one room out of two, of which one is already taken. The service is bookable only where a free practitioner and a free room exist at the same instant. Service Dental check 45 min, grid 15 min buffer_after 10 min requirement 1 1 of group Practitioners role: dentist requirement 2 1 of group Rooms role: room dr_hall dr_ng, taken dr_sato room_1, taken room_2 Bookable where a free practitioner and a free room overlap: two combinations here, not five.
Requirements are intersected, never added. The engine computes the free time of each requirement, keeps the instants where all of them are satisfiable at once, and returns the concrete combinations in resource_options. When two requirements can only be served by the same single resource, the instant is not offered at all rather than offered and then refused.
## Policy: the rules, frozen at the moment of sale A policy holds the rules that apply after the booking exists: refund tiers by distance from the start, a reschedule fee and a limit on how many times, a no show charge and its grace period, a hold duration, whether a booking needs confirming, and how many active bookings one customer may hold. The important part is not the fields. It is that the policy is **copied into the booking** as `policy_snapshot` when the booking is made. Change the policy tomorrow and yesterday's booking still cancels under yesterday's terms, because the terms are in the row, not behind a foreign key. This is a contract question before it is a technical one, and it is the single most common way a homemade booking system quietly refunds the wrong amount. Deposits and payment timing are described in the policy and honoured as numbers. **No money moves**: there is no payment provider yet, so a refund is an expectation the API computes (`refund_percent`, `refund_amount_expected`) and `amount_refunded` never changes on its own. [Policies](/docs/guides/policies/) is the whole of it. ## Customer: who booked A customer is a person in your system, not in ours. `external_id` is your identifier, and the API upserts on it; an inline `customer` object on a hold or a booking creates or finds one by email, so a first booking does not need two calls. A customer carries the limits a policy applies to them, such as `max_active_bookings_per_customer`, which is decided under an advisory lock so two simultaneous bookings cannot both slip past it. ## Hold: a short lease on capacity A hold takes the capacity out of availability without creating a booking, for a `ttl` bounded by the policy and capped at 30 minutes. It exists for the gap between "the customer chose a slot" and "the customer finished the form". A hold either becomes a booking, is released, or expires. An expired hold is ignored by the engine from the instant it expires, before any sweep touches the row, so an expired hold never blocks anybody even for a second. Converting an expired hold is a clean `409 hold_expired`, and the honest answer to it is to ask for availability again rather than to assume the slot is still there. ## Booking: capacity taken A booking is the fact. It carries the instants in UTC, the local time zone it was sold in, the duration in minutes, the quantity, the price, the frozen policy, and the allocations: which resource gave which units. Its status moves through a matrix that is data in the engine, not scattered `if` statements.
The booking transition matrix A table of three rows and six columns. From pending, the allowed actions are confirm, cancel and reschedule. From confirmed, they are cancel, reschedule, check in, complete and no show. From in progress, they are cancel, complete and no show. Every other combination is refused with 409 invalid transition. confirm cancel reschedule check_in complete no_show pending confirmed in_progress cancelled, completed, no_show and rescheduled are terminal. Every empty cell is 409 invalid_transition, naming what is legal from here.
The matrix as the engine holds it. check_in has an automatic twin, start, applied by a job at the instant the policy says, and complete and no_show have the same. A completed booking keeps its occupancy, because the service happened and the period is in the past; a cancelled or no show booking gives the capacity back.
Two consequences worth knowing before you build against it. - **A reschedule is not an update.** It creates a second booking and links the two, `rescheduled_from_booking_id` and `rescheduled_to_booking_id`, and leaves exactly one occupancy standing. Two hundred simultaneous reschedules of the same booking produce one winner and 199 `invalid_transition`, which is a test, not a hope. - **A configuration change never edits a booking.** Move the opening hours under a booking that is already sold and Bookrail emits `booking.orphaned` and leaves the row exactly as it was. Silently deleting money and commitments is not an option the system has. ## Event and webhook: how it leaves the system Every change writes an event in the same transaction that made it. The log is append only, and not by convention: the application role has no `UPDATE` and no `DELETE` on that table. Events are read with a cursor on `(txid, seq)` rather than on a timestamp, which is what makes the cursor hole free under concurrent writers. A webhook endpoint turns the same events into signed deliveries.
From a hold to a signed delivery A chain of five boxes: hold, booking, event, outbox and delivery. A hold either converts into a booking or expires and gives the capacity back. The booking and its event are written in one transaction. The outbox converts events into deliveries behind the visibility horizon, and the delivery is a signed POST retried on a fixed ladder. hold capacity leased ttl up to 30 min booking capacity taken policy frozen event same transaction cursor (txid, seq) delivery signed POST 8 attempts, 24 h expires: capacity back, no booking, and no event to deliver The outbox sits between event and delivery. It converts only events older than the oldest running transaction, so no event is ever skipped.
Delivery is at least once, so deduplicate on Bookrail-Event-Id. The signature is HMAC-SHA256 over "<timestamp>.<raw body>", the secret is shown once at creation and encrypted at rest, and an endpoint that resolves to a private address is refused at delivery time, not only at registration. Webhooks has the details.
## What the model deliberately does not have - **No calendar object.** A calendar is a rendering of resources and schedules, and putting one in the model would force every vertical through somebody else's idea of a week. - **No appointment type separate from service.** One object, with requirements, covers both. - **No user object for end customers.** Authentication of your users is yours. A customer is a record, never an account with a password here. - **No availability table.** Availability is computed, never stored, so it cannot go stale. What is stored is the occupancy, which is a fact. Waitlists, entitlements and payments are in the plan and not in the API: there is no endpoint behind them today, and this page will say so until there is. --- # Entity reference Source: https://bookrail.dev/docs/entities/ The whole model, with no vertical-specific feature in it. If a business cannot be expressed with these, the model changes; a module is never added. ## Location Where things happen. Carries a `timezone`, which is the fallback wall clock for the resources that belong to it. ## Resource Anything that can be occupied: a person, a room, a court, a vehicle, a class, a table. Fields that matter: `type` (free text: `staff`, `room`, `court`, `vehicle`, ...), `capacity` (how many units it serves at once), `attributes` (free JSON, used by group selectors), `status`, `schedule`, `location`. A resource with `capacity: 1` cannot be double booked: an exclusion constraint in Postgres covers every active occupancy on it, so the guarantee survives a bug in the application. ## ResourceGroup A named set of resources plus an allocation strategy: `first_available`, `least_busy`, `round_robin` or `priority` (the order the members are declared in). A service requires a group when the customer does not care which member serves the booking. ## Schedule Opening hours, as rules on a local clock plus exceptions. - A rule has `days` (local days of the week), `from`, `to`, and an optional validity window. - An `open` exception adds a band on one date; a `closed` exception with hours subtracts one. - A `closed` exception **without** hours suppresses the whole local day, including the bands its rules would have produced. Closures win over openings. - A band whose end is not strictly after its start crosses midnight. ## Service What is sold. Exactly one duration form: `duration`, `durationOptions` or `durationRange`. Plus the grid (`slotInterval`, `alignTo`), the buffers, the price and its `pricingRules` (prices that depend on the day, the hour, the date, the resource or the duration: see `bookrail docs config`), the booking window, the policy, and the requirements. ### Requirements A service needs one or more resources *at the same time*: a scan needs a sonographer, a room and the machine. Each requirement names a resource or a group, a `quantity`, a `consumes` (`per_unit` or `whole`) and an optional `role`, which comes back on the allocation so a client can tell which resource played which part. ## Policy Money and lifecycle rules, frozen onto each booking at creation as `policy_snapshot`, so a policy edited later never changes what a customer was promised. Cancellation and reschedule tiers, deposit, no-show, hold duration, confirmation requirements, `autoStart`, `autoComplete`, `maxReschedules`. ## Customer The minimum: `external_id`, `email`, `phone`, `name`, `timezone`, `locale`. Anything else stays in your system, linked by `external_id`. ## Booking The commitment. Statuses: `held`, `pending`, `confirmed`, `in_progress`, `completed`, `cancelled`, `no_show`, `rescheduled`. Carries its allocations, its frozen price and policy, and the expected refund, no-show charge and reschedule fee computed at each transition. ## Hold A short reservation of the capacity while a customer pays or fills in a form. It occupies the resource exactly like a booking until it expires or is converted. ## Event Append-only. One event per transition, written in the same transaction as the change, and delivered to webhook endpoints with an HMAC signature. ## Amounts and instants Amounts are integers in the minor unit of the currency, never floats. Instants are ISO 8601 with an explicit offset on the way in, and UTC plus a `timezone` field on the way out. --- # Configuration Source: https://bookrail.dev/docs/configuration/ The whole booking model as code: a file you keep in git, diff before applying, and push. ```ts import { defineConfig } from 'bookrail'; export default defineConfig({ locations: [{ id: 'club', name: 'Club', timezone: 'Europe/Rome' }], schedules: { club_hours: { timezone: 'Europe/Rome', rules: [{ days: ['mon', 'tue', 'wed', 'thu', 'fri'], from: '08:00', to: '23:00' }], }, }, resources: [ { id: 'court_1', name: 'Court 1', type: 'court', location: 'club', schedule: 'club_hours' }, ], resourceGroups: { courts: { resources: ['court_1'], allocationStrategy: 'first_available' }, }, policies: { prepaid: { cancellation: [{ before: '12h', refundPercent: 100 }, { before: '0h', refundPercent: 0 }], deposit: { type: 'percent', value: 100 }, holdDuration: '10m', }, }, services: [ { id: 'match', name: 'Match', durationOptions: [60, 90], slotInterval: 30, alignTo: 'hour', price: { amount: 3000, currency: 'EUR' }, policy: 'prepaid', requirements: [{ group: 'courts', quantity: 1 }], }, ], }); ``` ## Ids are logical `id` is your own name for the object. The push stores it in `metadata.config_id` on the remote object; that is the entire mapping. A `res_...` identifier never appears in a config file, and the same file applies to test and to live. Every collection accepts two spellings: an array of objects each carrying `id`, or a record keyed by that id. `name` defaults to the id. ## What push does - Matches by `metadata.config_id`. - Creates and updates in dependency order: location, schedule, resource, group, policy, service. Deletes in the exact reverse order. - **Never touches an object that has no `metadata.config_id`.** Objects created through the API or a dashboard are listed as unmanaged and left alone. - Refuses to delete anything without `--yes`. - Is idempotent: running it twice is the same as running it once, and running it again after a failure converges. ## Field reference `bookrail schema config --json` prints the full JSON Schema, generated from the same validation the push uses. `bookrail schema services --json` prints one collection. Notable fields, and what they are for: | Field | Where | What it does | |---|---|---| | `capacity` | resource | How many units the resource can serve at once. Capacity 1 is enforced by a database exclusion constraint, not by application code. | | `consumes` | requirement | `per_unit` takes `quantity` units; `whole` takes the resource entirely. A yoga instructor is `whole`: one person whatever the class size. | | `allowSplit` | service | Lets one booking take capacity from several resources of a group (eight covers over two tables of four). | | `bufferBefore` / `bufferAfter` | service | Minutes kept free around the booking. They are not sold to anyone. | | `bufferSharing` | service | Lets the buffers of two adjacent bookings overlap **each other**, never the body of the other booking. | | `bookingWindow` | service | `minNoticeMinutes` and `maxAdvanceDays`. Minutes and days, not duration strings. | | `durationRange` | service | A rental or a meeting room: availability answers continuous ranges instead of a grid. | | `alignTo` | service | `hour`, `half_hour` or `schedule_start`: where the slot grid is anchored. | | `pricingRules` | service | Prices that depend on the slot. Evaluated in order; the first match wins. See below. | | `holdDuration` | policy | How long a hold survives. `"10m"`, or a number of seconds. | | `autoStart` / `autoComplete` | policy | Let the scheduler move a booking to `in_progress` and to `completed` by itself. | | `noShow.autoMark` | policy | Lets the scheduler mark a no-show after the grace period. | | `maxReschedules` | policy | How many times one booking may be moved. | ## Prices that depend on the slot A service has one `price`. `pricingRules` changes it for the slots that match a condition: ```ts { id: 'match', price: { amount: 3000, currency: 'EUR' }, pricingRules: [ { when: { days: ['sat', 'sun'] }, price: 3500, label: 'Weekend' }, { when: { timeFrom: '18:00', timeTo: '22:00' }, priceAdd: 500, label: 'Evening' }, { when: { durationMin: 90 }, priceMultiplier: 1.4 }, ], } ``` The rules are evaluated **in order and the first match wins**: there is no chaining, so a Saturday evening costs 3500, not 4000. A rule that matches nothing costs nothing; a rule placed after one that always matches is dead, which is why a `when` with no condition at all is refused. - `when` is an **and**: `days`, `timeFrom`/`timeTo`, `dateFrom`/`dateTo`, `resourceId`, `durationMin`. All of them read the **local clock of the offer**, never the customer's, and all of them are evaluated on the **start** of the slot. - `timeFrom`/`timeTo` is half open, `[from, to)`, and wraps when `to` is before `from`: `22:00`-`02:00` is the night rate. Give both or neither. - a band that wraps past midnight is written **without `days`, or with both days it touches**. Every condition is read on the **start** of the slot, `days` included, so `{ days: ['fri'], timeFrom: '22:00', timeTo: '02:00' }` covers Friday 22:30 and not Saturday 00:30, which is the other half of the same night. Write `['fri', 'sat']` for the whole night, and accept that it also covers the Saturday evening, or leave `days` out. - `dateFrom`/`dateTo`, unlike the time band, are independent: give both for a season, or one alone for a range open at that end. `{ dateFrom: '2026-07-01' }` is "from July on"; both dates are inclusive. - exactly one of `price` (replace), `priceAdd` (add, may be negative, never goes below zero) and `priceMultiplier` (scale, at most four decimals, rounded to the minor unit). - `label` is free text, at most 60 characters, and comes back in `price_rule`. - a service with **no** `price` cannot have rules: there is nothing for them to modify, and the push is refused with a `400`. The price a slot shows is the price the booking freezes. Changing the rules afterwards does not change a booking that has already been made, and neither does rescheduling it. **One limit worth knowing.** `when.resourceId` is a real `res_...` identifier, not a logical id of this file: `push` does not resolve references inside pricing rules. Take the id from `bookrail resources list` or from a `bookrail pull`. ## Time zones in a config A schedule's `timezone` is the wall clock its `from`/`to` are read on. A rule whose `to` is not strictly after its `from` crosses midnight: `22:00`-`02:00` is four hours and `00:00`-`00:00` is the whole local day. Fixed dates (a tour that runs three Wednesdays) are a schedule with **no rules** and one `open` exception per date. --- # The edge cases of booking Source: https://bookrail.dev/docs/edge-cases/ Booking looks like inserting a row. It is not. It is a distributed allocation problem with a calendar, a clock that changes twice a year, and a contract attached to every row, and almost all of the work is in the cases nobody demoed. This page is the honest list. For each case: what goes wrong, what this system does, and the **test in the repository that proves it**. The test names are real; they are what you would run. The repository is public, so every path below is a link to the file it names, and [Open source](/docs/open-source/) says what is open and what is not. Use the list as a checklist against whatever you are building, ours or your own. A passing demo illustrates a case; it does not establish correctness under load. ## Capacity ### Two customers, one slot **What goes wrong.** Two requests arrive in the same millisecond for a resource of capacity 1. The classic implementation reads availability, sees a free slot, and writes: both reads succeed, both writes succeed, and the club has two people on one court. **What Bookrail does.** The check is a database constraint, not a read followed by a write. A capacity 1 resource carries a Postgres exclusion constraint over the period of every active occupancy, so the second write fails at the constraint whatever the application believed. One request gets the booking, the other gets `409 slot_unavailable` naming the requirement that could not be served. **Proved by.** [`packages/engine/test/concurrency.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/concurrency.test.ts), scenario `capacity` at capacity 1: three separate processes with their own connections fire simultaneous requests at one slot and exactly one wins. [`packages/engine/test/booking.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/booking.test.ts) goes under the engine and writes raw SQL as the application role: `refuses two overlapping occupancies on a capacity 1 resource, application role included` and `refuses the double booking even when the application check is bypassed`. [`packages/db/test/occupancies.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/db/test/occupancies.test.ts) asserts the constraint on its own, with no engine in the way: `rejects two overlapping occupancies on a capacity-1 resource`. ### Capacity N, and the unit past the last one **What goes wrong.** A class of 15 is not a court. Exclusion constraints do not express "at most 15 overlapping", so most systems fall back to `SELECT count(*)` and a hope. **What Bookrail does.** A trigger measures the peak of `capacity_used` over the footprint of the new occupancy and refuses the unit that would pass the resource capacity. The measurement is one SQL function, used by the trigger and by the engine, and a property test checks it against the TypeScript version. **Proved by.** [`packages/db/test/occupancies.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/db/test/occupancies.test.ts), `the capacity guard for capacity N`: `accepts occupancies up to the capacity`, `refuses the unit past the capacity with a check violation, written in raw SQL`, and `refuses it on the admin connection too`. [`packages/engine/test/concurrency.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/concurrency.test.ts) runs the same scenario at capacity 3 and 15. ### Composite resources: two requirements, one candidate **What goes wrong.** A service needs a dentist and a room. Three dentists and two rooms are free, but the only dentist who can do this treatment is the one who owns the only free room. Naive systems offer the slot and then fail at booking time, or double allocate the same resource to both requirements. **What Bookrail does.** Requirements are intersected and the assignment is solved with backtracking before anything is written. If the only solution needs one resource in two roles, the instant is not offered. **Proved by.** [`packages/engine/test/booking.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/booking.test.ts): `finds the assignment two requirements sharing a resource make necessary` and `refuses when two requirements can only be served by the same single resource`. [`packages/engine/test/availability.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/availability.test.ts): `offers nothing when two requirements fight over the same resource`. Under load, [`packages/engine/test/concurrency.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/concurrency.test.ts) scenario `composite`. ### Capacity lowered under bookings that already exist **What goes wrong.** Someone edits a resource from capacity 4 to capacity 1 while four bookings overlap on it. The tempting behaviour is to accept the edit and let the overbooking exist, or to delete bookings to make the numbers fit. **What Bookrail does.** Neither. The exclusion constraint is re-evaluated for the occupancies that already exist, so the edit is refused when it would create an overlap that the new capacity forbids; and where the edit is accepted, existing bookings are never deleted. Where a change does leave a future booking inconsistent, the system emits `booking.orphaned` and leaves the booking exactly as it was. **Proved by.** [`packages/db/test/occupancies.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/db/test/occupancies.test.ts): `re-flags existing occupancies when the capacity of a resource changes`, `closes the hole opened by lowering the capacity under a saturating occupancy`, `refuses to lower the capacity to 1 while two occupancies already overlap`. [`packages/engine/test/orphaned.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/orphaned.test.ts): `reports a capacity lowered under what is already booked`. ### A configuration change orphans a booking **What goes wrong.** The club shortens its opening hours, or deactivates a court, and there are already bookings in the part that just disappeared. Systems either delete them, or pretend nothing happened and discover it on the day. **What Bookrail does.** The booking is **not touched**, and a `booking.orphaned` event is emitted in the same transaction as the configuration change, with a reason: `outside_schedule`, `capacity_exceeded` or `resource_unavailable`. Deciding what to do with a sold booking is a business decision and belongs to you; the system's job is to tell you the moment it happens rather than to silently destroy a commitment. A nightly job repeats the scan past the interactive horizon of 90 days. **Proved by.** [`packages/engine/test/orphaned.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/orphaned.test.ts): `reports a booking left outside the opening hours, and does not touch it`, `reports a closed-day exception`, `reports a resource that was deactivated or soft deleted`, `writes one event per booking even when several of its resources are affected`. Also [`packages/api/test/orphaned.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/orphaned.test.ts) and [`packages/api/test/reconcile.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/reconcile.test.ts) for the nightly pass. ## Time ### The clocks change: 23 hour and 25 hour days **What goes wrong.** Schedules are stored as UTC offsets, or as local strings converted once. On the last Sunday of March the 02:30 slot does not exist; on the last Sunday of October it exists twice. Systems built on offsets produce ghost slots, duplicate slots, or a whole day of bookings an hour out. **What Bookrail does.** Rules live on a local clock and are materialised into UTC one local day at a time from the IANA database. A day with a transition has 23 or 25 hours, and a `09:00` to `19:00` rule still lasts ten real hours on both. A `01:00` to `04:00` rule lasts two real hours in spring and four in autumn, because the change falls inside it. A non-existent local time is pushed forward by the length of the jump; a repeated one takes its first occurrence. Durations are absolute minutes: a 60 minute booking at 01:30 lasts 60 real minutes. **Proved by.** [`packages/engine/test/dst.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/dst.test.ts), over six zones with the transition dates read from the tzdata on the machine rather than typed: `makes the local day` 23 hours long on one transition and 25 on the other (the title is built from the measured offset change, so the number in it is computed and not typed), `keeps a 09:00-19:00 schedule at ten real hours`, `turns a 01:00-04:00 rule into three hours minus the offset change it contains`, `moves a band that falls entirely inside the gap forward, keeping its length`, `resolves a repeated wall-clock time to its first occurrence`, `keeps eroded start instants 60 real minutes apart across the change`. [Time zones](/docs/guides/time-zones/) is the guide. ### A band that crosses midnight **What goes wrong.** A bar open 22:00 to 02:00, closed on Wednesday. Does Tuesday night stop at midnight? Most calendars are built on days, so the tail of Tuesday belongs to Wednesday and disappears with it. **What Bookrail does.** A closure without hours suppresses the bands the rules of that day would have produced, night tail included, and does **not** touch the tail of a band that started the day before. Tuesday night still serves until 02:00. A closure with hours is subtracted, and it applies even on a suppressed day, because a closure can cross midnight onto a day that does open. **Proved by.** [`packages/engine/test/availability.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/availability.test.ts): `materializes a band that crosses midnight`. [`packages/engine/test/dst.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/dst.test.ts): `carries a 22:00-02:00 band across midnight into the transition day`. [`packages/engine/test/booking.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/booking.test.ts): `reports every local day a booking crosses, midnight included`, which is what keeps the cache invalidation correct on both sides of the boundary. ### The customer is in another time zone **What goes wrong.** The buyer's zone is used for the calculation, so the same court appears open at different hours to different people, and the local date in the confirmation email is wrong for one of them. **What Bookrail does.** Availability is computed on the **offer**: the resource, its location and its schedule. The `timezone` in the request is presentation only, and moves the local column of the answer without moving the grid by one minute. Instants go in with an explicit offset and come out in UTC. A bare date is a `400`, with the reason spelled out, because midnight is not the same moment everywhere. **Proved by.** [`packages/engine/test/availability.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/availability.test.ts): `computes on the resource, never on the customer time zone`, plus `aligns the grid to the local hour in Asia/Kolkata (+05:30)` and `in Asia/Kathmandu (+05:45)` for the offsets that are not whole hours. ### Buffers meet another booking **What goes wrong.** Two services on one chair, one needing 10 minutes of cleanup and the other 15 of setup. Systems that apply the asking service's buffers to somebody else's booking compute a distance that is not the real one, and either sell an impossible slot or hide a legal one. **What Bookrail does.** An existing occupancy carries **its own** buffers, stored on the row, and the new booking carries its own. With `buffer_sharing: false` the two footprints stay disjoint. With `buffer_sharing: true` the buffers may overlap each other but never the body of the other booking, and the minimum distance drops accordingly. A block carries no buffer: it is subtracted as it is. **Proved by.** [`packages/engine/test/booking.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/booking.test.ts), four tests to the minute: `without buffer_sharing the two buffers add up: 11:49 is refused`, `without buffer_sharing 11:50 is exactly far enough`, `with buffer_sharing the buffers overlap: 11:29 is still refused`, `with buffer_sharing 11:30 is enough, thirty minutes earlier than without`. ### A price that depends on the hour the clocks change **What goes wrong.** A night rate on `[02:00, 03:00)` and a clock change underneath it. In spring that hour does not exist; in autumn it happens twice. A system that computes the local hour from a stored offset either prices an hour nobody can book or prices only one of the two that exist, and the difference lands on a customer's card. **What Bookrail does.** A rule is read on the local wall clock of the **offer**, on the start of the slot, through the IANA database. Going from an instant to a wall time is total, so there is nothing to disambiguate and nothing to guess: on the spring forward night no instant reads 02:30, so the rule fires for none of them, and on the fall back night two instants do, so it fires for both. The weekday is the local one too, so a slot at 00:30 on a Saturday in Rome is priced as Saturday even though UTC is still on the Friday. **Proved by.** [`packages/engine/test/pricing.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/pricing.test.ts), with the transition dates read from the tzdata rather than typed: `cannot fire on the hour that does not exist in %s`, `fires twice on the hour that happens twice in %s`, `reads days on the local day of %s, even when UTC is still on the day before`, and `prices the day before a clock change as the day it is locally`. ### A price band that crosses midnight, and two rules that both match **What goes wrong.** A night rate written `22:00` to `02:00`. Read as a plain interval it is empty and never fires. And once several rules exist, a system that applies all of them turns a weekend rate plus an evening supplement into a number nobody quoted. **What Bookrail does.** A band is half open and wraps: `[22:00, 24:00)` united with `[00:00, 02:00)`, so 22:00 and 00:30 are both in it and 02:00 is not. The rules are evaluated in order and the **first match wins**, with no chaining: a Saturday evening under a weekend rule followed by an evening rule costs the weekend price, and the evening rule never runs. The slot says which rule priced it, `price_rule: { index, label }`, so the number can always be traced back. `price_add` never takes a price below zero, and a multiplier is rounded to the minor unit. **One thing to know before you write one.** Every condition is read on the **start** of the slot, `days` included, so a wrapping band combined with a single weekday covers half the night it looks like it covers: `{"days": ["fri"], "time_from": "22:00", "time_to": "02:00"}` prices Friday 22:30 and not Saturday 00:30. Write a night rate **without `days`**, or with both days it touches (`["fri", "sat"]`), accepting that the second one also covers that day's evening. **Proved by.** [`packages/engine/test/pricing.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/pricing.test.ts): `prices a band that crosses midnight on both sides of it`, `reads the time band as half open, [from, to)`, `reads days on the day the slot starts, so a band that wraps needs both days`, `lets the first matching rule win, with no chaining`, `never lets price_add take the price below zero`, `rounds a multiplier to the minor unit, the two worked examples of the specification`, and the property tests `is deterministic: the same input gives the same answer`, `never produces a negative amount, and never leaves the integers` and `always reports the first rule that matches, never a later one`. ### The price changes between the quote and the invoice **What goes wrong.** Availability quotes 40.00, the customer books, and somebody edits the price list that afternoon. A system that recomputes on read now shows a different number on the booking, and the refund, the reschedule fee and the no-show charge all follow it. **What Bookrail does.** The booking **freezes** the price and the rule that produced it, in the same transaction that takes the capacity, exactly as it freezes the policy. Changing the rules afterwards changes nothing about it, and neither does a reschedule: the new booking is priced for the slot it moved to, and the old one keeps what it froze. A hold is a quote, not a sale, so the price is computed again at the conversion: the creation of a hold quotes an amount, the hold itself stores none, and reading a hold back answers `price: null` rather than a number that could differ between two reads. The no-show charge is a percentage of the frozen price, surcharge included. **Proved by.** [`packages/engine/test/lifecycle.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/lifecycle.test.ts): `freezes the price a pricing rule produced, and charges the no-show on it`, `does not move a frozen price when the rules change afterwards`, `prices a hold at conversion, not at the hold`, `prices the new booking of a reschedule for the slot it moved to`. [`packages/api/test/bookings.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/bookings.test.ts) runs the same chain over HTTP: `freezes the price a pricing rule produced, and names the rule on the booking`. [`packages/engine/test/availability.cache.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/availability.cache.test.ts) covers the other half, that a cache cannot hide a rule change: `reflects a change to pricing_rules in the next answer, with the cache still warm`. ## The life of a booking ### A hold expires halfway through the conversion **What goes wrong.** The customer picks a slot, a hold is taken, the form takes four minutes, the hold had a two minute lease. Systems that sweep expired holds on a timer leave a window where an expired hold still blocks the slot, or where a dead hold still converts into a booking. **What Bookrail does.** Expiry is a property of the row and not of the sweep. The engine ignores a hold from the instant it expires, so an expired hold never blocks anybody, and `GET /v1/holds/{id}` answers `expired` before any job has run. Converting an expired hold is a clean refusal, not a booking. The right client behaviour is to ask for availability again, not to assume the place is still free. **Proved by.** [`packages/engine/test/booking.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/booking.test.ts): `refuses to convert a hold that has expired` and `ignores an expired hold, sweeps it, and books the slot it was holding`. [`packages/api/test/holds.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/holds.test.ts), whose title for it reads "says `expired`" followed by `as soon as the deadline has passed, before any sweep has run`, and `409s on a hold that has already become a booking`. [`packages/engine/test/concurrency.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/concurrency.test.ts) scenario `hold_race` runs holds against direct bookings on the same slot. ### The policy changed after the sale **What goes wrong.** A booking is made under "free cancellation up to 24 hours". Two weeks later the club moves to 48 hours. The customer cancels 30 hours out and the system applies today's rule to yesterday's contract, refunding the wrong amount in the wrong direction. **What Bookrail does.** The policy is copied into the booking as `policy_snapshot` at creation. Every later calculation, refund, reschedule fee, no show charge, reads the snapshot. The current policy is irrelevant to a booking that already exists. **Proved by.** [`packages/engine/test/booking.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/booking.test.ts): `freezes the policy snapshot at creation time`. [`packages/engine/test/lifecycle.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/lifecycle.test.ts): `picks the tier at the exact boundary, not a millisecond either side`, `computes the refund on amount_paid, not on the price`, `cancels a booking whose policy carries an impossible percentage` for a snapshot that was already malformed when it was frozen. ### Rescheduling, and the limit on it **What goes wrong.** Reschedule is implemented as an `UPDATE` of the start. Two clients reschedule at once and both succeed, leaving two occupancies or none; or the reschedule succeeds and the old slot is never released; or a customer moves the same booking forty times. **What Bookrail does.** A reschedule creates a second booking, links the two in both directions, and leaves exactly one live occupancy. `max_reschedules` comes from the frozen snapshot. A new start that is not on the service grid is refused rather than rounded. **Proved by.** [`packages/engine/test/lifecycle.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/lifecycle.test.ts): `moves the booking, links the two, and leaves exactly one occupancy`, `leaves the old booking untouched when the new slot is gone`, `respects max_reschedules`, `refuses a new start that is not on the service grid`, `never lets a concurrent reader see the slot free while the reschedule runs`. Under load, [`packages/engine/test/concurrency.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/concurrency.test.ts) scenario `reschedule_race`: 200 simultaneous reschedules of one booking onto one capacity 1 slot, one winner, 199 `invalid_transition`, and a dedicated check that the chain is still coherent afterwards. ### A no show marked too early **What goes wrong.** The customer is nine minutes late, somebody hits "no show", and the penalty is charged against a policy that promised a fifteen minute grace. **What Bookrail does.** The grace period comes from the frozen snapshot and the transition is refused before it, to the minute. When the policy says nothing about a no show charge, nothing is charged. The automatic version, `no_show.auto_mark`, is applied by a job at the same instant the manual one would become legal. **Proved by.** [`packages/engine/test/lifecycle.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/lifecycle.test.ts): `refuses a no-show before the grace period and accepts it at the exact minute`, `charges nothing for a no-show when the policy says nothing`, `keeps the occupancy of a completed booking, and releases that of a no-show`. ### Every other illegal transition **What goes wrong.** Confirm a cancelled booking. Complete one that has not started. Cancel one twice. Each of these is a state machine question, and a system that answers them with scattered `if` statements answers some of them wrongly. **What Bookrail does.** The matrix is data, the row is locked `FOR UPDATE` for the transition, and everything the matrix does not allow is `409 invalid_transition` naming the current status and the actions that are legal from it. **Proved by.** [`packages/engine/test/lifecycle.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/engine/test/lifecycle.test.ts): `exposes the documented transition matrix, and nothing more`, `refuses every action the matrix does not allow, from every status`, `names the current status and the allowed actions in the 409`, `refuses to complete a booking that has not started`, `applies an automatic transition once, whatever the number of workers`. ## Talking to the API ### A response gets lost, and the client retries **What goes wrong.** The booking succeeded, the connection dropped before the response arrived, the client retries. Systems that implement idempotency as "look up the key, then write if absent" have a race exactly the width of that lookup, and two concurrent retries produce two bookings. **What Bookrail does.** The key is **taken**, with an `INSERT` under a unique constraint, before the work runs. Twenty simultaneous requests with one key leave one booking. The stored answer is replayed with `Idempotent-Replayed: true`, including a 4xx. The key is released only when nothing was committed: a `5xx` raised after the commit is stored and replayed, because releasing it there would let the retry book a second time. The same key with a different body is `400 idempotency_key_reused`; a request still in flight is `409 idempotency_key_in_progress`. **Proved by.** [`packages/api/test/idempotency.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/idempotency.test.ts): `lets exactly one of twenty concurrent requests with one key create a booking`, `replays the same response and creates one booking only`, `replays a 4xx answer verbatim instead of retrying it`, `keeps the key, so the retry gets the same 500 and never a second booking`, `still releases the key when nothing was committed`, `refuses the same key with a different body`, `refuses the same key on a different endpoint`, `does not burn the key on a POST to a path that does not exist`. [Idempotency](/docs/guides/idempotency/) is the guide. ### A webhook endpoint that is disabled, or slow, or gone **What goes wrong.** A receiver goes down. A queue keeps firing at it forever, or gives up silently, or, worse, keeps delivering to an endpoint the customer explicitly disabled. **What Bookrail does.** Deliveries are taken with `FOR UPDATE SKIP LOCKED` and the query excludes disabled endpoints, so work already queued for a disabled endpoint stops leaving. Failures retry on a fixed ladder, 3s, 30s, 5m, 30m, 2h, 12h and 24h, eight attempts, and then the delivery is `failed`, the endpoint is `failing`, and a `webhook.failing` event is written (and never itself delivered to anybody). A delivery that succeeds brings the endpoint back to `active`. Any delivery can be sent again by hand. **Proved by.** [`packages/api/test/webhook-delivery.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/webhook-delivery.test.ts): `stops delivering what is already queued when the endpoint is disabled`, `retries on exactly 3s, 30s, 5m, 30m, 2h, 12h, 24h and then gives up`, `replays a dead delivery and brings the endpoint back to active`, `never delivers webhook.failing to anybody`, `records a timeout as a failure, with no status and a reason`, `sends the deliveries of a tick in parallel, so one slow receiver holds nobody`. ### A webhook arrives twice, or out of order **What goes wrong.** At least once delivery is the only kind that exists, so a consumer that assumes exactly once double books, double emails, or double refunds. And an older event arriving after a newer one can undo a state transition that already happened. **What Bookrail does.** A unique constraint on `(webhook_id, event_id)` makes a second delivery of one event to one endpoint impossible whatever the outbox does, and every delivery carries `Bookrail-Event-Id` to deduplicate on. The outbox reads the log on a `(txid, seq)` cursor and only past the oldest running transaction, so an event committed by a slow writer cannot be skipped by a fast one. Ordering across endpoints is not promised: the event object carries the state, and a consumer should treat a delivery as "read the current object", not as "apply this delta". **Proved by.** [`packages/api/test/webhook-delivery.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/webhook-delivery.test.ts): `does not deliver the same event to one endpoint twice, whatever the outbox does`, `takes each delivery once when two workers sweep together`, `append only: no row is ever updated by the delivery path`. [`packages/api/test/webhook-outbox.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/webhook-outbox.test.ts) covers the cursor and the visibility horizon. ### A webhook URL that points inside your network **What goes wrong.** A customer registers an endpoint on the cloud metadata address, `169.254.169.254`, and the delivery worker fetches credentials on their behalf. Or registers a public hostname that resolves to a private address only at delivery time, which validation at registration cannot catch. **What Bookrail does.** The full table of non public ranges is refused, IPv4 and IPv6 including `::ffff:127.0.0.1`, and the DNS is resolved **at delivery** with the connection pinned to the addresses that were verified, which is what closes the rebinding window that a plain `fetch` leaves open. No environment variable turns this off. **Proved by.** [`packages/api/test/webhook-ssrf.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/webhook-ssrf.test.ts), and [`packages/api/test/webhook-secrets.test.ts`](https://github.com/bookrail-dev/bookrail/blob/main/packages/api/test/webhook-secrets.test.ts) for the neighbouring promise that the signing secret is shown once and never returned by any serializer. ## What is not on this list Cases the model describes and the system does not implement yet, so there is nothing to prove: - **Recurring series with conflicting occurrences.** `recurrence` is a `400 not_yet_supported`. - **Multi day bookings across a closure.** The `allow_closed_gaps` case is designed, not built. - **Anything to do with money.** `payment.mode` other than `none` is a `400`. Refunds are computed as expectations and no amount ever moves. - **Rate limiting.** There is none. They will get an entry here when they get a test. ## Using this list Every heading above is a case worth running against whatever you use, including your own code. For each one, record the expected result before the test, then check the stored state as well as the API response: an API that answers correctly and stores two allocations has still lost. Use synthetic data and independent clients, and remove credentials before sharing anything. A coding agent can pull the same material with the MCP tool `bookrail_edge_cases`, which returns the topics as markdown from the packaged documentation, and [For AI agents](/docs/for-ai-agents/) explains the rest of that surface. --- # API Source: https://bookrail.dev/docs/api/ Base URL from `BOOKRAIL_API_URL`. Every request carries: ``` Authorization: Bearer sk_test_... Bookrail-Version: 2026-09-01 ``` Every response carries `Bookrail-Request-Id` and `Bookrail-Version`. Every `POST` accepts `Idempotency-Key`; the same key within 24 hours returns the same response with `Idempotent-Replayed: true` and causes no second effect. ## Who am I ``` GET /v1/project ``` Answers the project the calling key belongs to (`id`, `name`, `environment`, `api_version`, `default_timezone`, `default_currency`) plus `api_key` with the key's own `id`, `kind`, `scopes` and `tenant_id`. Singular and with no id in the path: the key **is** the selector, so `GET /v1/projects` and `GET /v1/project/{id}` are `404 unknown_endpoint`. No secret material is returned. It is what `bookrail whoami` and `bookrail doctor` call. ## Configuration CRUD ``` POST /v1/locations GET /v1/locations GET|PATCH|DELETE /v1/locations/{id} POST /v1/resources GET /v1/resources GET|PATCH|DELETE /v1/resources/{id} POST /v1/resources/{id}/block POST /v1/resources/{id}/unblock POST /v1/resource_groups GET /v1/resource_groups GET|PATCH|DELETE /v1/resource_groups/{id} POST /v1/schedules GET /v1/schedules GET|PATCH|DELETE /v1/schedules/{id} POST /v1/schedules/{id}/exceptions DELETE /v1/schedules/{id}/exceptions/{eid} POST /v1/services GET /v1/services GET|PATCH|DELETE /v1/services/{id} POST /v1/policies GET /v1/policies GET|PATCH|DELETE /v1/policies/{id} POST /v1/customers GET /v1/customers GET|PATCH|DELETE /v1/customers/{id} ``` Lists are `{"object":"list","data":[...],"has_more":true}` and paginate by cursor: `?limit=50&starting_after=`. Never by offset, and there is no total. `PATCH` with `rules` (schedule), `resource_ids` (group) or `requirements` (service) replaces the whole set; omitting the field leaves the set alone. `slot_interval` and `align_to` on a service accept `null`, which removes the slot grid; omitting them leaves it as it is. `DELETE` answers `{"id":"...","object":"...","deleted":true}`. Resources and services are soft deleted so that past bookings keep pointing at what they were made for; everything else is a real delete, and references from other objects become `null`. `?expand[]=` works for `resource.schedule`, `resource_group.resources`, `service.requirements`, `booking.customer` and `booking.allocations.resource`. ## Availability ``` POST /v1/availability { service_id, from, to, quantity?, resource_ids?, customer_id?, timezone?, granularity?: "slots"|"ranges", explain?: boolean } GET /v1/availability/next?service_id=...&from=...&quantity=...&timezone=... POST /v1/availability/check { service_id, start, duration_minutes?, quantity?, resource_ids? } ``` Slots come back in UTC, with `available_capacity`, `price`, `price_rule` and `resource_options`: the concrete combinations of resources that could serve the booking, each with the units it would contribute. `explain: true` returns, for every rejected instant, the structured reasons (`outside_schedule`, `exception_closed`, `blocked`, `occupied`, `buffer`, `min_notice`, `max_advance`, `capacity`, `customer_limit`); the window is capped at seven days for it and at ninety days otherwise. With `explain: true` the answer also carries `explain_notes`: what the engine had to ignore to answer at all, with no instant of its own. Today that is `pricing_rule_ignored`, a rule stored on the service that the strict schema refuses; it is skipped, the price comes from the next rule that matches, and `bookrail availability --explain` prints the note above the table. `price` is not always the flat price of the service. If the service carries `pricing_rules`, the first rule whose `when` matches the slot decides, and `price_rule` says which one: `{"index": 0, "label": "Weekend"}`, or `null` when the flat price applied. `bookrail availability` shows it as a `rule` column. ## Holds and bookings ``` POST /v1/holds { service_id, start, duration_minutes?, quantity?, resource_ids?, customer_id? | customer{}, ttl?: "10m" } DELETE /v1/holds/{id} POST /v1/bookings { service_id, start, hold_id?, quantity?, resource_ids?, customer_id? | customer{}, notes?, metadata? } GET /v1/bookings/{id} GET /v1/bookings?customer_id=&service_id=&resource_id=&status=&from=&to= POST /v1/bookings/{id}/confirm|check_in|complete|no_show POST /v1/bookings/{id}/cancel { reason?, by?: "customer"|"provider"|"system", override_refund_percent? } POST /v1/bookings/{id}/reschedule { start, resource_ids? } ``` A hold occupies the capacity: create one, then convert it by passing `hold_id` to the booking. Converting a hold that expired is `409 hold_expired`, and the client asks for availability again. ## Events and webhooks ``` GET /v1/events?type=&object_id=&from=&to= GET /v1/events/{id} POST /v1/webhooks { url, events?: ["*"], description? } GET|PATCH|DELETE /v1/webhooks/{id} POST /v1/webhooks/{id}/test GET /v1/webhooks/{id}/deliveries?status=&event_id= POST /v1/webhooks/{id}/deliveries/{did}/retry ``` The signing secret is returned **once**, by the creation call. Deliveries carry `Bookrail-Signature: t=,v1=.">`, and are retried at 3s, 30s, 5m, 30m, 2h, 12h, 24h before the endpoint is marked `failing`. --- # SDK for TypeScript Source: https://bookrail.dev/docs/sdk/ 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. ```bash npm install @bookrail/node ``` ## Quick start ```ts 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); ``` ### The field names are the API's 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. ## Configuration ```ts 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. ## Every call takes request options ```ts 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 }, }); ``` ## Retries and idempotency 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. ```ts 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 ``` ## Pagination ```ts 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. ## Errors ```ts 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`. ## Webhooks ```ts 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. ## What is in the box `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. ## Not here yet 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. ## Documentation - [Quickstart](https://bookrail.dev/docs/quickstart/), timed against the production API. - [Concepts](https://bookrail.dev/docs/concepts/): the data model, with figures. - [API reference](https://bookrail.dev/docs/api/reference/): 67 operations, generated from the executable contract, `packages/api/openapi/openapi.json`, which this package's types are generated from too. - [Idempotency](https://bookrail.dev/docs/guides/idempotency/) and [Webhooks](https://bookrail.dev/docs/guides/webhooks/). ## Status 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`](https://www.npmjs.com/package/@bookrail/node), Apache 2.0, with its source in [github.com/bookrail-dev/bookrail](https://github.com/bookrail-dev/bookrail) under `packages/sdk-node`. ## Licence Apache-2.0. --- # Errors Source: https://bookrail.dev/docs/errors/ Every error, from the API and from the CLI, has a machine code, a human message, the guilty field when there is one, a documentation URL, and, from the CLI, a `fix`: one sentence that says what to do next. ```json { "ok": false, "environment": "test", "error": { "code": "slot_unavailable", "message": "The requested slot is no longer available. 1 unit requested, 0 available.", "param": "start", "doc_url": "https://bookrail.dev/docs/errors#slot_unavailable", "fix": "The capacity is gone. Run `bookrail availability --service ... --explain` to see what took it.", "request_id": "req_..." } } ``` ## Exit codes | Code | Meaning | |---|---| | 0 | Success | | 1 | User or configuration error. Re-running the same command fails the same way. | | 2 | Authentication or permission. | | 3 | Network, timeout, rate limit or server fault. Retrying later may work. | | 4 | Conflict: the state changed underneath. Retrying may work right now. | ## Types `invalid_request` (400), `authentication` (401), `permission` (403), `not_found` (404), `conflict` (409), `rate_limit` (429), `policy_violation` (422), `payment_required` (402), `internal` (500). ## Codes you will meet | Code | Type | When | |---|---|---| | `missing_api_key`, `invalid_api_key`, `revoked_api_key` | authentication | No key, a malformed one, or one that was revoked. | | `live_key_without_live` | authentication (CLI) | A `sk_live_` key is configured and `--live` was not typed. Nothing was sent. | | `parameter_missing`, `parameter_invalid`, `invalid_body` | invalid_request | The request body. | | `resource_missing` | not_found | An id that does not exist in this project and environment. | | `slot_unavailable` | conflict | The capacity is gone. The message carries the units requested and available. | | `hold_expired`, `hold_not_active` | conflict | The hold died or was already used. | | `serialization_failure` | conflict | Nothing was written. Run the command again. | | `idempotency_key_in_progress` | conflict | Another request with the same key is still running. | | `idempotency_key_reused` | invalid_request | The same key was used for a *different* request. | | `start_not_on_grid` | policy_violation | The instant is not on the slot grid of the service. | | `min_notice_violated`, `outside_booking_window` | policy_violation | The booking window of the service. | | `customer_limit_reached` | policy_violation | `maxActiveBookingsPerCustomer` is reached. | | `duration_not_offered` | invalid_request | Not one of the service's durations. | | `resource_not_eligible` | invalid_request | A forced resource is not a candidate of any requirement. | | `invalid_transition` | conflict | The booking's state does not allow that action. | | `no_show_too_early`, `complete_too_early` | policy_violation | Too early for that transition. | | `max_reschedules_reached` | policy_violation | The policy's limit. | | `range_too_large`, `invalid_range`, `timezone_missing` | invalid_request | Availability requests. | | `invalid_webhook_url` | invalid_request | Not public, or `http` on live. | Two codes that people expect and that do **not** exist: `capacity_exceeded` (a quantity above capacity is `slot_unavailable`, whose message is more precise) and `schedule_conflict` (a calendar change that invalidates a future booking is not an error: it emits a `booking.orphaned` event and leaves the booking alone). ## CLI-only codes | Code | Meaning | |---|---| | `config_not_found` | No `bookrail.config.*` in the working directory. | | `invalid_config` | The file parsed but does not validate. The message lists each position. | | `config_unreadable` | The file could not be evaluated. Usually a TypeScript config on a Node without a TypeScript loader. | | `confirmation_required` | The command would delete something and `--yes` was not given. | | `ambiguous_config_id` | Two remote objects carry the same `metadata.config_id`. | | `missing_input` | A required value was not given and there was no terminal to ask on. | | `network_error`, `timeout` | The API could not be reached. | --- # Time zones Source: https://bookrail.dev/docs/guides/time-zones/ Availability is computed on the **offer**, never on the buyer. A schedule's rules are written on a local clock; they are materialised into UTC day by day with the IANA database, so the day a clock changes has 23 or 25 hours and the rules still mean what they say locally. - A rule's day of the week is the **local** day. A "Monday" rule in Auckland covers Sunday evening in UTC. - Durations are always absolute minutes. A 60 minute booking at 01:30 on the night of the change lasts 60 real minutes, whatever the wall clock says. - A multi-day booking "Friday 10:00 to Monday 10:00" is computed locally and converted: it lasts 71 or 73 hours when it crosses a change. ## Daylight saving Band ends are converted with `disambiguation: compatible`: - a **non-existent** time (02:30 on the March night) is pushed **forward** by the length of the jump, so 02:30 becomes 03:30 and the band keeps its wall-clock shape; - a **repeated** time (02:30 on the October night) takes the **first** occurrence, the one still on summer offset. Consequences, all under test: a `01:00`-`04:00` rule lasts 2 real hours on the spring-forward day and 4 on the fall-back day, when the change falls inside the band; a `09:00`-`19:00` rule lasts 10 real hours on both. A band whose two ends both fall inside the jump does not vanish: it moves forward whole and keeps its wall-clock length. A band whose ends collapse onto the same instant does vanish. Slots inside a non-existent hour do not exist, not by a special rule, but because that hour is not in the timeline. ## Rules, exceptions and blocks: the order For each local day: 1. A `closed` exception **without hours** suppresses the day: the bands its rules would have produced are not generated at all, night tail included. It does **not** touch the tail of a band that started the day before: a bar open Tuesday 22:00-02:00 and closed on Wednesday still serves until 2 a.m. on Tuesday night. 2. The bands of the rules whose `days` contain the local weekday, and whose validity window (both ends inclusive) contains the day. `validUntil` limits the days a rule may *start* on, not the instants it produces. 3. The bands of that day's `open` exceptions, in addition to the rules, or alone if the day has no rules. On a day suppressed by step 1 they are suppressed too: **closures beat openings**. 4. `closed` exceptions **with hours** are subtracted. They apply even on a suppressed day, because a closure can cross midnight onto a day that does open. 5. Blocks are subtracted whole: inside a block the capacity is 0, whatever the resource's capacity. 6. Each surviving segment carries the resource's capacity. Overlapping bands are unioned **booleanly** before capacity is applied: two rules covering the same instant open the resource once, not twice. ## Other edge cases handled and tested 1. Two simultaneous requests for the last seat: one wins, the other gets `slot_unavailable`. 2. A hold that expires between the availability call and the confirmation: `hold_expired`. 3. A schedule change that invalidates a future booking: the booking is **not** touched; a `booking.orphaned` event is emitted. 4. A capacity reduction below existing bookings: same. 5. Overlapping buffers: buffers are not bookable, but they constrain the admissible starts. An existing occupancy carries **its own** buffers, not those of the service asking. With `bufferSharing: true` two buffers may overlap each other, never the body of the other booking. Blocks carry no buffer. 6. A booking across midnight, across a clock change, or across a closure in the middle. 7. A quantity above one resource's capacity but available across a group: `allowSplit: true`. 8. Slots partly covered by a block: eroded correctly. 9. Availability over huge windows: capped at 90 days per call, 7 with `explain`. 10. A booking in the past: refused. 11. A customer in another time zone: availability is computed on the resource and presented in the zone asked for. 12. A pricing rule on the hour the clocks change: see below. ## Prices that depend on the clock `pricingRules` are read on the **local clock of the offer**, on the **start** of the slot. Four cases follow from that, and all four are tested: 1. **A band that crosses midnight.** `timeFrom: '22:00', timeTo: '02:00'` is half open and wraps: it covers 22:00 to 23:59 and 00:00 to 01:59, and not 02:00 itself. Combine it with `days` and the wrap becomes visible: the day is the day the slot **starts** on, so `days: ['fri']` on that band covers Friday 22:30 and not Saturday 00:30. A night rate is written without `days`, or with both days it touches (`['fri', 'sat']`). 2. **The night the clocks go forward.** No instant reads 02:30 local, so a rule about `[02:00, 03:00)` matches nothing that night. The hour does not exist; a rule about it cannot fire, and the slots on either side keep the price they would have had. 3. **The night the clocks go back.** Two instants read 02:30 local, and the rule matches **both**. The hour happens twice, and both times cost what the rule says. 4. **A local day that is not the UTC day.** `days: ['sat']` is about the Saturday of the club. A slot at 00:30 on Saturday in Rome is 22:30 on Friday in UTC and is priced as Saturday; the same rule in Auckland moves the other way. The zone is the offer's, never the caller's. Two more things worth knowing before you write a rule: the **first** matching rule wins and nothing chains after it, and `priceAdd` never takes a price below zero. The price a slot shows is the price the booking freezes, so a rule changed afterwards does not move a booking that has already been made. ## The same rules, over HTTP Everything above is the engine. This is what it looks like from the API, and the three habits that keep an integration out of trouble. ### Instants in carry an offset, instants out are UTC `from`, `to` and `start` are ISO 8601 with an explicit offset. A bare date is refused, and the error says why rather than guessing for you: ``` Error [parameter_invalid] --from must be an ISO 8601 instant with an explicit offset, got "2026-09-14". param: from Fix: Write it as `2026-09-08T07:00:00Z` or `2026-09-08T09:00:00+02:00`. A bare date is refused because midnight is not the same instant in every time zone. ``` Every instant in a response is UTC with a `Z`. Format it for the reader at the edge of your system, never in the middle of it. ### `timezone` is presentation, and only presentation `POST /v1/availability` takes an optional `timezone`, and `bookrail availability --tz` sets it. It changes the local column of the answer and nothing else. The same window asked twice, once in `Europe/Rome` and once in `Asia/Tokyo`, returns the same UTC instants: ``` [test] 16 slot(s), times shown in Asia/Tokyo start (UTC) local end (UTC) min cap price ------------------------ ---------------- ------------------------ --- --- --------- 2026-09-14T06:00:00.000Z 2026-09-14 15:00 2026-09-14T07:00:00.000Z 60 1 30.00 EUR 2026-09-14T06:30:00.000Z 2026-09-14 15:30 2026-09-14T07:30:00.000Z 60 1 30.00 EUR ``` Those are the same instants a request in `Europe/Rome` returns, at 08:00 and 08:30 local. The grid belongs to the court, not to whoever is asking. A booking stores the zone it was sold in (`timezone` on the booking), so a confirmation can be rendered in the local time of the offer without guessing later. ### The window has limits, and they are 400s, never 500s - A window wider than **90 days** is `400 parameter_invalid`. - `explain` is capped at **7 days**, because it materialises a row per rejected instant. - `GET /v1/availability/next` searches up to 90 days ahead and answers with the first bookable instant, which is usually the call you want instead of paging through a month. ### Reading a schedule back `bookrail pull` writes the current project out as a `bookrail.config.ts`, with the schedules in local time exactly as they are stored, and `bookrail diff` tells you whether a file and a project agree. Neither converts anything to UTC on the way, because a schedule written in UTC is a schedule that will be wrong in six months. --- # Webhooks Source: https://bookrail.dev/docs/guides/webhooks/ Every change in a project writes an event, in the same transaction that made the change. A webhook endpoint turns those events into signed POSTs. This page is the whole of it: register, verify, retry, replay, and the rule about which URLs are allowed to exist. ## Register an endpoint ```bash npx bookrail webhooks create \ --url https://example.com/hooks/bookrail \ --events booking.created --events booking.cancelled ``` ``` [test] created wh_01a0804128b974cba21b5c55d26fe2f1 -> https://example.com/hooks/bookrail subscribed to: booking.created, booking.cancelled signing secret: whsec_... This is the only time the secret is shown. It is stored encrypted and no endpoint will ever return it again. If you lose it, the only remedy is to create another endpoint. Put it in your receiver now (BOOKRAIL_WEBHOOK_SECRET) and verify every delivery against it. ``` Over HTTP the same call is `POST /v1/webhooks` with `url` and `events`. `*` subscribes to everything; `webhook.*` is refused, because an endpoint that is told about its own failures would be told by the mechanism that is failing. The secret is shown once, in the creation response, and encrypted at rest. No serializer has a branch that can emit it again, which is a test rather than a convention (`packages/api/test/webhook-secrets.test.ts`). Losing it means creating another endpoint. An endpoint has a `status`: `active`, `failing` or `disabled`. You can set `disabled`, and you cannot set `failing`: that one is the system's opinion, not yours. ## What a delivery looks like ``` POST /hooks/bookrail HTTP/1.1 Content-Type: application/json Bookrail-Signature: t=1789012345,v1=6f1c... Bookrail-Event-Id: evt_01a08041defd7583b1ea5f6e378e9e23 Bookrail-Webhook-Id: wh_01a0804128b974cba21b5c55d26fe2f1 Bookrail-Delivery-Id: whd_... ``` The body is the event object: `id`, `type`, `created_at`, `actor`, and `data: { object, previous }`. `previous` carries the fields that changed, so a consumer can tell a confirmation from a reschedule without keeping its own history. Two headers matter operationally. `Bookrail-Event-Id` is what you deduplicate on: delivery is **at least once**. `Bookrail-Delivery-Id` is what you quote when you ask why an attempt behaved the way it did. ## Verify the signature `Bookrail-Signature` is `t=,v1=`, and `v1` is `HMAC-SHA256(secret, ".")`. The timestamp is inside the signed payload deliberately. Without it a signature is valid forever and a captured delivery can be replayed at any point in the future; with it, a receiver that also checks the age of `t` has a bounded window. The default tolerance is 300 seconds, in both directions, because clock skew is symmetric. ```bash npm install @bookrail/webhook-signature ``` ```ts import express from 'express'; import { verifySignature } from '@bookrail/webhook-signature'; app.post('/hooks/bookrail', express.raw({ type: 'application/json' }), (request, response) => { const ok = verifySignature( request.body.toString('utf8'), // the raw bytes, as they arrived request.get('Bookrail-Signature'), process.env.BOOKRAIL_WEBHOOK_SECRET!, ); if (!ok) return response.sendStatus(400); const event = JSON.parse(request.body.toString('utf8')); // Deduplicate on event.id, then act. response.sendStatus(200); }); ``` With `@bookrail/node` the same thing is `bookrail.webhooks.constructEvent(rawBody, header, secret)`, which verifies and parses in one call and throws `BookrailSignatureVerificationError` when it does not match. Three rules that account for most of the failures people hit: 1. **Pass the raw bytes.** A body that was parsed and re-encoded will not verify: two JSON encoders disagree about key order and whitespace. In Express that means `express.raw`, not `express.json`. 2. **`verifySignature` returns `false`, it never throws.** A verifier that throws on a malformed header turns a forged request into a 500 instead of a 400, so this one does not. 3. **The comparison is constant time**, and a header may carry several `v1=` values so that a secret can be rotated without a window of failures. The verifier accepts if any of them matches the secret you hold. Bookrail does not rotate secrets yet, but the receiver you deploy today keeps working on the day it does. The package has **zero runtime dependencies** and reaches only `node:crypto`. ## Watch deliveries land, locally ```bash npx bookrail webhooks listen --url https:// --port 4100 ``` With a public URL the CLI registers a temporary endpoint pointed at it, listens on the local port, verifies every signature it receives, and deletes the endpoint when the command exits (`--keep` leaves it). It does not open the tunnel: use `ngrok`, `cloudflared` or whatever you already have. Without a URL it polls the event log instead, and says so rather than pretending: ``` [test] no --url given, so nothing was registered: following the event log instead. The objects below are exactly what a delivery would have carried, byte for byte, but no endpoint was called and no signature was produced. [test] following the event log every 2s. Ctrl-C to stop. 2026-09-08T09:03:15.475Z booking.cancelled bk_01a0804124a974cfb34faa100c8dc6cf [test] stopped (max) after 1 event(s). ``` ## Retries A delivery succeeds on any `2xx`. A `3xx` is a failure and no redirect is followed. Everything else, timeouts included, goes onto a fixed ladder: | Attempt | After | | --- | --- | | 1 | immediately | | 2 | 3 s | | 3 | 30 s | | 4 | 5 min | | 5 | 30 min | | 6 | 2 h | | 7 | 12 h | | 8 | 24 h | After the eighth the delivery is `failed`, the endpoint moves to `failing`, and a `webhook.failing` event is written. That event is never delivered to anybody, for the obvious reason. A delivery that succeeds later brings the endpoint back to `active`. The request timeout is 10 seconds, and deliveries of one tick go out in parallel, so one slow receiver does not hold up anyone else's. Answer quickly and do the work afterwards. A receiver that books a table, sends an email and charges a card before returning 200 will time out and be retried, and then it will do all three again. ## Replay, and the delivery log ```bash npx bookrail webhooks deliveries wh_01a0804128b974cba21b5c55d26fe2f1 npx bookrail webhooks retry wh_01a0804128b974cba21b5c55d26fe2f1 whd_... ``` `GET /v1/webhooks/{id}/deliveries` lists attempts with their status, response code, duration and next attempt; `POST /v1/webhooks/{id}/deliveries/{did}/retry` sends one again. There is no 30 day archive and no replay of a whole time range: there are eight attempts across 24 hours, and a manual retry. ## Exactly once does not exist, and here is what does - **No event is skipped.** The outbox reads the log on a `(txid, seq)` cursor and only past the oldest running transaction, so an event committed by a slow writer cannot be jumped over by a fast one. - **No event is delivered twice to one endpoint by the sender.** A unique constraint on `(webhook_id, event_id)` makes it impossible whatever the outbox does. - **A delivery can still arrive twice at your door**, because the network exists and a timeout after your handler committed looks exactly like a failure. Deduplicate on `Bookrail-Event-Id`. - **Order across endpoints is not promised.** Treat a delivery as "this object changed, here it is", not as a delta to apply. ## Which URLs are allowed An endpoint URL must resolve to a public address, and that is checked **at delivery time**, not only at registration. The DNS is resolved when the POST is about to be made and the connection is pinned to the addresses that were verified, which closes the rebinding window that a plain `fetch` leaves open. The full table of private and reserved ranges is refused, IPv4 and IPv6, including `::ffff:127.0.0.1` and `169.254.169.254`. No environment variable turns this off. For local development, use a tunnel, which is a public address, or `bookrail webhooks listen` without a URL, which polls. ## Events you can subscribe to `--events` is validated against a closed list and anything outside it is refused. The list has two halves, and the difference matters. **Emitted today**, twelve types: ``` booking.created booking.confirmed booking.checked_in booking.started booking.completed booking.cancelled booking.no_show booking.rescheduled booking.orphaned hold.created hold.released hold.expired ``` **Subscribable but not emitted yet**, because the feature behind them does not exist: `booking.updated`, `booking.reminder_due`, `hold.converted`, `waitlist.*`, `payment.*`, `entitlement.*`, `resource.updated`, `resource.blocked`, `schedule.updated`, `availability.changed`. Registering for one of these is accepted and will simply never fire until the feature ships. We would rather you could write the subscription once than have the API reject a name it is going to accept in three months. `*` subscribes to everything including types added later. `webhook.failing` and `webhook.test` exist in the log and are never delivered to any endpoint. `booking.orphaned` is the one people miss. It fires when a configuration change leaves a sold booking inconsistent, and it is the only warning you get that somebody edited the calendar under a customer. See [The edge cases of booking](/docs/edge-cases/). --- # Idempotency Source: https://bookrail.dev/docs/guides/idempotency/ Every `POST` under `/v1` accepts an `Idempotency-Key` header. Send one and a retry of that request cannot produce a second booking, whatever happened to the first response. This is not a convenience. A booking API without it is a booking API that double books every time a mobile connection drops at the wrong moment, which is often. ## Use it ```bash curl -s https://api.bookrail.dev/v1/bookings \ -H "Authorization: Bearer $BOOKRAIL_SECRET_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: order-4711' \ -d '{"service_id":"svc_...","start":"2026-09-16T06:00:00Z","duration_minutes":60, "customer":{"email":"ada@example.com"}}' ``` Send it again and the response is byte for byte the first one, with a header saying so: ``` HTTP/2 201 bookrail-request-id: req_2b119f20e3bc04f555dacee3 bookrail-version: 2026-09-01 idempotent-replayed: true ``` The SDK does it for you. Every `POST` carries a key; if you do not pass one, one is generated and **the same one** is sent on every retry of that call, which is exactly what makes retrying a POST safe: ```ts await bookrail.bookings.create(params, { idempotencyKey: order.id }); ``` The CLI carries one on every `POST` too, so `bookrail bookings create` run twice by a nervous operator does not book twice, as long as the retry is the same command. ## Why it actually holds The common implementation is "look the key up, and if it is not there, do the work". That has a race exactly as wide as the lookup, and under real concurrency two retries land in it. Bookrail **takes** the key instead: an `INSERT` under a unique constraint, before any work runs. The database decides who owns the key. Twenty simultaneous requests carrying one key produce one booking and nineteen replays or `409 idempotency_key_in_progress`, and that is a test with twenty real clients, not an argument. ``` packages/api/test/idempotency.test.ts lets exactly one of twenty concurrent requests with one key create a booking ``` ## What a retry gets back | Situation | What happens | | --- | --- | | First request succeeded | The stored response, with `Idempotent-Replayed: true` | | First request failed with a 4xx | The same 4xx, replayed verbatim | | First request is still running | `409 idempotency_key_in_progress`. Wait and retry | | Same key, different body | `400 idempotency_key_reused` | | Same key, different endpoint | `400 idempotency_key_reused` | | First request 5xx'd **after** committing | The same 500, replayed. The key is not released | | First request 5xx'd with nothing committed | The key is released, so the retry does the work | That last pair is the subtle one. Releasing a key after a failure sounds generous, and it is correct only when nothing was written. When a request committed a booking and then fell over formatting the response, releasing the key would let the retry book a second time. So the rule is: **the key is freed only if no effect was committed**, and a 5xx after a commit is stored and replayed forever after. Keys expire after 24 hours, and an hourly job removes the expired ones. They are scoped to the project and to the environment, so a test key and a live key never collide. ## Choosing a key - **Use an identifier that already exists in your system**: the order id, the cart id, the row id of the intent. A key that is regenerated on retry is not a key. - **One key, one request.** Do not reuse a key for a different body, and do not reuse it across endpoints: both are a `400`, and deliberately, because it almost always means the caller thinks it is retrying something it is not. - **A UUID is fine** when there is nothing better, as long as you keep it for the whole life of the retry loop. That is what the SDK does. - Surrounding whitespace is trimmed, an empty key is refused, and an over long one is refused, with no row written for either. ## What idempotency is not - **It is not a lock on a slot.** Two different keys for the same slot are two different requests, and the second gets `409 slot_unavailable` if the capacity has gone. If you need to hold a slot while a customer fills in a form, that is a [hold](/docs/concepts/#hold-a-short-lease-on-capacity). - **It does not make a `GET` safe to repeat.** Reads are already repeatable, and the header is simply harmless there. It is accepted on `POST /v1/availability`, which is a read spelled as a POST because the query does not fit in a URL. - **It is not deduplication of webhooks.** That is `Bookrail-Event-Id`, on the receiving side. See [Webhooks](/docs/guides/webhooks/). ## When to retry at all The SDK retries `429`, every `5xx`, connection failures, timeouts, and the two `409` codes that mean "come back in a moment": `idempotency_key_in_progress` and `serialization_failure`. It never retries any other `4xx`, and never after your own `AbortSignal` fired. The backoff is 0.5 s, 1 s, 2 s, 4 s, 8 s with 25 percent jitter, and a `Retry-After` header wins. If you are writing your own client, copy that list. Retrying a `400` is how a retry loop turns one bad request into a thousand. --- # Policies Source: https://bookrail.dev/docs/guides/policies/ A policy is the set of rules that apply **after** a booking exists: what a cancellation refunds, what a move costs, what happens when nobody turns up, and how long a hold lives. Two things about it are worth reading before anything else. 1. **The policy is frozen into the booking at the moment of sale**, as `policy_snapshot`. The live policy is irrelevant to a booking that already exists. 2. **No money moves.** There is no payment provider yet. Everything below is computed and written as an expectation, and `amount_refunded` never changes on its own. The section at the bottom says exactly which fields are real numbers and which are intentions. ## The shape of a policy ```ts policies: { prepaid: { name: 'Prepaid', cancellation: [ { before: '12h', refundPercent: 100 }, { before: '0h', refundPercent: 0 }, ], deposit: { type: 'percent', value: 100 }, paymentTiming: 'at_booking', holdDuration: '10m', autoComplete: true, }, } ``` Over HTTP the same object is `POST /v1/policies` in `snake_case`. A duration is digits plus one of `s`, `m`, `h`, `d`. A bare number is **not** a duration and is refused: it used to be read as milliseconds, so `{ "before": 24 }` meant 24 milliseconds and refunded zero without saying anything. ## Cancellation: tiers by distance from the start Tiers are ordered from furthest to nearest, and the first one that is still true wins, which is the most generous applicable one. ```json [ { "before": "48h", "refund_percent": 100 }, { "before": "24h", "refund_percent": 50 }, { "before": "0h", "refund_percent": 0 } ] ``` - **The edge of a tier is inclusive.** `before: "48h"` still applies at exactly 48 hours from the start and no longer applies at 47 hours 59 minutes 59.999 seconds. That is tested to the millisecond on every edge, because "about two days" is not a contract. - **After the start nothing is refunded**, unless a `{ "before": "0h" }` tier says otherwise, and even then only up to the instant of the start. - **No tiers, or no policy at all, refunds nothing.** An absent rule is not a promise. - **The refund is computed on `amount_paid`, not on the price.** Refunding a percentage of a price nobody paid is how a system refunds money it never took. - **A malformed tier is dropped, not fatal.** The snapshot is a copy of a row that may be years old, and refusing to cancel a booking because of a two year old typo would be the wrong error. Malformed covers an unreadable `before` and values out of range: a `refund_percent` outside 0 to 100 and a negative fee are dropped like the rest. A provider cancellation (`by: "provider"`) refunds 100 percent, unless `override_refund_percent` says otherwise. That override is the explicit way to grant a refund your own policy does not allow, and it is recorded as an override, so the trail stays honest. ```bash npx bookrail bookings cancel bk_... --yes ``` ``` [test] cancelled bk_01a0804124a974cfb34faa100c8dc6cf · cancelled field value --------------- ------------------------------------ start (UTC) 2026-09-14T16:00:00.000Z price 30.00 EUR refund 100% (0 minor units expected) Next steps - The refund is an expectation, not a movement: payments do not exist yet, so `amount_refunded` is untouched. ``` 100 percent of nothing is nothing, and the CLI says both parts. ## Reschedule: a fee, and a limit The same tier structure with `fee` instead of `refund_percent`, plus `max_reschedules` per booking. The fee comes from the tier applicable to the distance between now and the start of the **old** booking, the one whose terms the customer accepted, and it is written as `reschedule_fee_expected` on the **new** booking, which is the one a payment would attach to. `max_reschedules` is compared with `reschedule_count`, which the new booking inherits incremented; past the limit it is `422 max_reschedules_reached`. A reschedule is not an update: it creates a second booking, links the two in both directions, and leaves exactly one live occupancy. The price difference between the old slot and the new one is **not** computed yet; the new booking freezes the service price as it stands at the moment of the move. ## No show - `grace_minutes`: how long after the start a booking may be marked as a no show. Before that instant it is `422 no_show_too_early`, manual or automatic. Without a policy the grace is 0. - `charge_percent`: `no_show_charge_expected` is `floor(price × charge_percent / 100)`, on the **price** rather than on what was paid, because it is a charge and not a refund. Out of range values count as 0. - `auto_mark: true` schedules the marking at creation and reschedules it after every transition. A check in removes it. An automatic start does not, because starting is not the same as turning up. Marking a no show **releases the occupancy**: the customer did not come, and the rest of the slot goes back on sale. A completed booking keeps its occupancy, because the service happened. ## Prices that depend on the slot A service has one `price`. `pricing_rules` changes it for the slots that match a condition, and the price a slot shows is the price its booking freezes. ```ts { id: 'match', name: 'Match 60', duration: 60, price: { amount: 3000, currency: 'EUR' }, pricingRules: [ { when: { days: ['sat', 'sun'] }, price: 4000, label: 'Weekend' }, { when: { timeFrom: '18:00', timeTo: '23:00' }, priceAdd: 1000, label: 'Evening' }, ], } ``` Pushed to a real project, one court open 09:00 to 23:00 Rome, this is what `bookrail availability` answers. Friday: ``` start (UTC) local price rule ------------------------ ---------------- --------- ---------- 2026-09-25T07:00:00.000Z 2026-09-25 09:00 30.00 EUR base ... 2026-09-25T16:00:00.000Z 2026-09-25 18:00 40.00 EUR #1 Evening 2026-09-25T20:00:00.000Z 2026-09-25 22:00 40.00 EUR #1 Evening ``` Saturday: ``` start (UTC) local price rule ------------------------ ---------------- --------- ---------- 2026-09-26T07:00:00.000Z 2026-09-26 09:00 40.00 EUR #0 Weekend ... 2026-09-26T20:00:00.000Z 2026-09-26 22:00 40.00 EUR #0 Weekend ``` Saturday evening is 40.00, **not** 50.00. That is the rule that matters most: the rules are evaluated in order and the **first match wins**, with no chaining. The weekend rule comes first, so it decides, and the evening rule never runs that day. Every slot carries the rule that priced it: ```json { "object": "availability_slot", "start": "2026-09-26T07:00:00.000Z", "end": "2026-09-26T08:00:00.000Z", "duration_minutes": 60, "available_capacity": 1, "price": { "amount": 4000, "currency": "EUR" }, "price_rule": { "index": 0, "label": "Weekend" } } ``` `price_rule` is `null` when the flat price applied. The booking freezes both: `booking.price` and `booking.price_rule` are what the slot said at the moment of sale, and they do not move afterwards, not when the rules change and not on a reschedule. ### The rules of a rule - **`when` is an and.** `days` (local weekday names), `time_from`/`time_to`, `date_from`/ `date_to` (inclusive), `resource_id`, `duration_min` (an equality, not a minimum). All of them are read on the **start** of the slot, on the **local clock of the offer**, never the caller's: a Saturday surcharge is the club's Saturday. - **`time_from`/`time_to` is half open**, `[from, to)`, and wraps when `to` is before `from`: `22:00`-`02:00` is the night rate. Give both or neither, and give two different times. - **Exactly one effect** per rule: `price` replaces, `price_add` adds (it may be negative, and the result never goes below zero), `price_multiplier` scales (at most four decimals, rounded to the minor unit with half rounding up, so `2500 x 1.15` is `2875` and `1999 x 0.8` is `1599`). - **`label`** is free text, at most 60 characters, and comes back in `price_rule`. - **A `when` with no condition at all is refused**, and so is a rule on a service with no `price`: there would be nothing to modify, and everything after an always-matching rule would be dead. - At most 100 rules. A malformed one is a `400 parameter_invalid` whose `param` names its index, `pricing_rules[3].when.time_from`. Two nights a year the local clock is not a function of the wall time, and the rules follow it rather than paper over it: on the spring forward night no instant reads 02:30, so a rule about `[02:00, 03:00)` matches nothing; on the fall back night two instants do, and it matches both. [The edge cases](/docs/edge-cases/) has the tests. ## Deposits and payment timing The policy describes them and the booking freezes them: - `payment_timing`: `at_booking`, `deposit_then_balance`, `after_service`, or `none`. - `deposit`: `{ type: 'percent' | 'fixed', value }`. They are honoured as data. They do not charge anything, because there is nothing to charge with. A request that asks for more, `payment.mode` other than `none`, is a `400 not_yet_supported` rather than a silent no-op. ## Holds, confirmation and customer limits The policy also carries the rules that shape the booking flow itself: - `hold_duration` (`hold_duration_seconds` in the API): the lease a hold gets by default, capped at 30 minutes. - `require_customer_confirmation` and `require_provider_confirmation`: a booking that needs one is created `pending` instead of `confirmed`. - `auto_start` and `auto_complete`: the automatic transitions, applied by a job at the instant they fall due. - `max_active_bookings_per_customer`: decided under an advisory lock on the customer, so two simultaneous bookings cannot both slip past it. Availability reports it as `reason: customer_limit_reached` with a `200` and no slots, rather than as an error. ## What is real and what is an expectation This is the honest table. Left column: written and enforced today. Right column: computed, stored, and waiting for payments to exist. | Enforced now | Computed as an expectation | | --- | --- | | Which tier applies, to the millisecond | `refund_amount_expected` | | `max_reschedules`, as a `422` | `reschedule_fee_expected` | | `grace_minutes`, as a `422` | `no_show_charge_expected` | | `hold_duration`, capped at 30 min | `deposit`, `payment_timing` | | `require_*_confirmation`, as a `pending` status | Any price difference on a reschedule | | `pricing_rules`, evaluated per slot and frozen | | | `max_active_bookings_per_customer` | | | `auto_start`, `auto_complete`, `no_show.auto_mark` | | `amount_paid`, `amount_due` and `amount_refunded` exist on the booking and stay at whatever you put there. Nothing in Bookrail moves them. Also not implemented, and named here so nobody plans around them: `refund_fee_fixed` (an administrative retention), entitlements as a payment mode, and tax. --- # Coding agents Source: https://bookrail.dev/docs/guides/agents/ Bookrail is built to be driven by a coding agent as well as by a person. Not as a demo: the CLI takes `--json` on every command, the MCP server exposes 36 tools with real input schemas, the API ships an OpenAPI document generated from the schemas that validate each request, and every documentation page is also served as markdown. [For AI agents](/docs/for-ai-agents/) is the reference for that surface: the files, the conventions, the exit codes, the errors. This page is the guide: how to actually put an agent to work on a booking flow without it doing damage. ## Two ways in, and when to use which **The MCP server** is for an agent working inside an editor or a chat: it discovers what it can do from `tools/list`, gets typed arguments, and gets structured errors with a `fix` field. ```bash npx bookrail mcp install --client claude-code # or cursor, vscode, windsurf, generic ``` That writes the `bookrail` entry into the client's own configuration and leaves every other server alone. It never writes a key. **The CLI** is for an agent that has a shell, which is most of them, and for anything scripted or run in CI. Every command takes `--json` and prints one envelope, so an agent parses one shape and never a table. They are not two implementations. The MCP server drives the CLI in process, so there is one HTTP client and one error contract behind both. A tool cannot behave differently from the command it wraps. ## The guardrails These matter more than the tool list, because an agent with a key is an agent that can cancel a real customer's booking. - **Test is the default and live is a wall.** The CLI is in the test environment unless `--live` is typed, and a `sk_live_` key used without it is refused before any request leaves the process. The MCP server needs `BOOKRAIL_MCP_ALLOW_LIVE=1` **and** a live key: one of the two alone does nothing. - **Irreversible operations preview first.** Through MCP, a destructive tool returns what it would do plus `requires_confirmation: true`, and only acts when called again with `confirm: true`. On the CLI the same operations need `--yes`. - **`push` shows a plan.** `--dry-run` computes and prints, changes nothing. Deletions in a plan need `--yes` on top. - **The tools carry annotations.** `readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint` are on every tool in [`/mcp/tools.json`](/mcp/tools.json), so a client that gates on them has something real to gate on. - **stdout belongs to the protocol.** The MCP server replaces `process.stdout.write` at startup so nothing but JSON-RPC frames can reach the transport, and every failure is an `isError` carrying `{ code, message, fix, doc_url }`. Give an agent a **test key only**, and give it a project of its own. That is one line of prevention worth more than every hint above. ## Give it the documentation, not a search box An agent works far better with the whole contract than with a chat about the contract. | What to hand it | Why | | --- | --- | | [`/llms.txt`](/llms.txt) | The index: one line per page, with the markdown URL of each. | | [`/llms-full.txt`](/llms-full.txt) | Every page concatenated. One fetch, whole documentation. | | [`/openapi.json`](/openapi.json) | 41 paths, 67 operations, generated from the request schemas. A live API serves the same document on `GET /openapi.json` without a key. | | [`/mcp/tools.json`](/mcp/tools.json) | Every tool with its input schema, read from the running server at build time. | Through MCP the same material is available without leaving the session: `bookrail_docs_search` and `bookrail_docs_get` read the packaged pages, `bookrail_schema` returns the schema of one entity, `bookrail_examples` returns a complete valid configuration for a vertical, and `bookrail_edge_cases` returns [the edge case list](/docs/edge-cases/) as markdown, by topic. There are also three guided prompts on the server: `add-bookings-to-app`, `model-my-vertical` and `debug-availability`. ## The order that works An agent that follows this order gets it right the first time. An agent that starts by writing a configuration from the schema alone usually does not. ```bash bookrail doctor --json # what is configured, what is missing bookrail examples --json # a complete, valid model to start from bookrail init --template --json # write bookrail.config.ts bookrail push --dry-run --json # read data.plan before applying anything bookrail push --json bookrail diff --json # data.has_changes must be false bookrail services list --json # read back the ids the API assigned bookrail availability --service svc_... --from ... --to ... --json bookrail bookings create --service svc_... --start ... --customer-email ... --json bookrail bookings get bk_... --json # close the loop on every write ``` Two habits are worth enforcing in a system prompt. 1. **Never build an instant.** Take `start` verbatim from the availability answer. A rounded clock time is how you get `start_not_on_grid`, and a bare date is refused outright because midnight is not the same instant everywhere. 2. **Read back after every write.** `bookings get` after `bookings create` costs one round trip and turns "the command seemed to work" into a fact. ## When it goes wrong, read `fix` Every error, from the CLI, from MCP and from the API, carries the same shape: ```json { "ok": false, "environment": "test", "error": { "code": "slot_unavailable", "message": "The requested slot is no longer available. 1 unit requested, 0 available.", "param": "start", "fix": "The capacity is gone. Run `bookrail availability --service ... --explain` to see what took it.", "doc_url": "https://bookrail.dev/docs/errors#slot_unavailable" } } ``` `fix` is an instruction, not a diagnosis. It is written for a machine to act on, and following it is almost always the right next step. The exit code says how to categorise the failure: `1` user or configuration, `2` authentication, `3` network or service (retrying is safe, every `POST` carries an `Idempotency-Key`), `4` conflict (re-read, then decide). `--explain` is the one to reach for when availability disagrees with expectation. It names the resource and the reason for every rejected instant: ``` 2 instant(s) rejected: occupied 4 local instant code resource why ---------------- -------- ------------------------------------ --------------------------------------------------- 2026-09-15 08:00 occupied res_01a0804110a97175a637c6fec7692f85 Court 1 is already taken during the booking window. 2026-09-15 08:00 occupied res_01a08041128171fdb467a328b0c51390 Court 2 is already taken during the booking window. ``` Through MCP that is `bookrail_explain_unavailable`, which takes one instant and answers the same question. ## One example, end to end This is the [quickstart](/docs/quickstart/) written as a script an agent can run without a human in the loop. It assumes `BOOKRAIL_SECRET_KEY` holds a **test** key. ```bash set -e mkdir club && cd club npx bookrail doctor --json npx bookrail init --template padel --json npx bookrail push --dry-run --json # inspect .data.plan, then apply npx bookrail push --json npx bookrail diff --json # .data.has_changes must be false SVC=$(npx bookrail services list --json | jq -r '.data.data[0].id') # Take a start from the answer, never from a clock. START=$(npx bookrail availability --service "$SVC" \ --from 2026-09-14T00:00:00+02:00 --to 2026-09-15T00:00:00+02:00 --json \ | jq -r '.data.slots[0].start') BK=$(npx bookrail bookings create --service "$SVC" --start "$START" --duration 60 \ --customer-email ada@example.com --json | jq -r '.data.id') npx bookrail bookings get "$BK" --json npx bookrail events list --json # every write left a trace npx bookrail bookings cancel "$BK" --yes --json ``` Nine commands. Against `https://api.bookrail.dev` the whole thing runs in under twenty seconds; the measured numbers are in the [quickstart](/docs/quickstart/#how-long-it-took). ## Modelling before building The most common failure is not a wrong call. It is an agent inventing an entity the model does not have, usually a "slot" table or a "calendar" object. Four questions, in this order: 1. **What is sold?** A Service, with exactly one duration form. 2. **What has to be free for it to happen?** Resources, listed as the Service's requirements. Several things at once means several requirements, not one bigger resource. 3. **How many at once?** `capacity` on the resource, `quantity` on the booking. 4. **What are the rules about time and money?** A Policy. See [Policies](/docs/guides/policies/). Do not model a slot. Slots are computed, never stored, which is exactly why they cannot go stale. `bookrail_examples` returns a working model for nine verticals, and starting from the closest one beats starting from the schema. --- # CLI basics Source: https://bookrail.dev/docs/cli-basics/ Bookrail is booking infrastructure: availability, holds, bookings, policies and webhooks behind one HTTP API. This CLI describes a project as code and talks to that API. ## The loop ```bash bookrail login # store a sk_test_ key, mode 600 bookrail init --template padel # write bookrail.config.ts bookrail push --dry-run # see what would be created bookrail push # create it bookrail diff # confirm the project matches the file bookrail doctor # check credentials, project, reachability, version, config ``` Then you operate on it: ```bash bookrail availability --service svc_... --from 2026-09-11T00:00:00Z --to 2026-09-12T00:00:00Z bookrail availability --service svc_... --from ... --to ... --explain # why an instant is not there bookrail holds create --service svc_... --start 2026-09-11T06:00:00Z --ttl 10m bookrail bookings create --service svc_... --start 2026-09-11T06:00:00Z --hold hold_... bookrail bookings cancel bk_... --yes bookrail webhooks create --url https://example.com/hooks/bookrail # the secret, once bookrail webhooks listen --url https:// --port 4100 # watch deliveries land bookrail events list --follow # watch the log ``` Everything is `test` until you type `--live`. A `sk_live_` key used without `--live` is a hard error, not a warning: no request built by this process can reach the live environment unless you asked for it. ## Where the key comes from 1. `BOOKRAIL_SECRET_KEY` in the environment, if set. 2. `~/.config/bookrail/credentials.json` (or `$XDG_CONFIG_HOME/bookrail/credentials.json`), written by `bookrail login` with mode 600. The key is never printed. `bookrail whoami` and `bookrail env` show it masked; `whoami` also names the project the key opens, its scopes and its `tenant_id`. ## Output Every command takes `--json` and prints ```json { "ok": true, "environment": "test", "data": { }, "next_steps": ["..."] } ``` and on failure ```json { "ok": false, "environment": "test", "error": { "code": "...", "message": "...", "param": "...", "doc_url": "...", "fix": "..." } } ``` Exit codes: `0` success, `1` user or configuration error, `2` authentication, `3` network or service, `4` conflict (the state changed under you; retrying may work). Colour and decoration are off whenever stdout is not a terminal, and always off with `--json`. --- # For AI agents Source: https://bookrail.dev/docs/for-ai-agents/ Everything on this page is machine readable somewhere else too. If you are an agent, the fastest path is [`/llms.txt`](/llms.txt), [`/openapi.json`](/openapi.json) and [`/mcp/tools.json`](/mcp/tools.json). ## The machine readable surface | File | What it is | | --- | --- | | [`/llms.txt`](/llms.txt) | One line per documentation page, with the markdown URL of each. | | [`/llms-full.txt`](/llms-full.txt) | Every documentation page concatenated, in markdown. | | [`/openapi.json`](/openapi.json) | OpenAPI 3.1, generated from the schemas that validate each request. 41 paths, 67 operations. | | [`/mcp/tools.json`](/mcp/tools.json) | Every MCP tool with its description, input schema and annotations, read from the running server. | | `.md` | Every page written in markdown is also served as markdown at the same URL with `.md` on the end, for example [`/docs/errors.md`](/docs/errors.md). The generated API reference pages are not: read `/openapi.json` instead. | A running Bookrail API serves the same specification on `GET /openapi.json`, without a key, so an agent that has an address can read the contract before it has a credential. ## Install ```bash # The CLI. It needs nothing installed. npx bookrail --help # The MCP server, wired into a coding agent by the CLI itself. npx bookrail mcp install --client claude-code # or cursor, vscode, windsurf, generic ``` `mcp install` writes or updates the `bookrail` entry in the client's own configuration file and leaves every other server alone. It never writes a key. The server reads `BOOKRAIL_SECRET_KEY` (or `BOOKRAIL_TEST_SECRET_KEY` and `BOOKRAIL_LIVE_SECRET_KEY`), `BOOKRAIL_API_URL`, and `BOOKRAIL_MCP_ALLOW_LIVE`. With no variables set it reads the same `~/.config/bookrail/credentials.json` that `bookrail login` writes. ## The order of operations that works The same order is in the server's own `instructions`, and it is the one an agent should follow the first time it meets a project. ```bash bookrail doctor --json # what is configured, what is missing bookrail init --template --json # write bookrail.config.ts bookrail push --dry-run --json # read data.plan before applying anything bookrail push --json # apply bookrail diff --json # data.has_changes must be false bookrail services list --json # read back the svc_ ids bookrail availability --service svc_... --from ... --to ... --json bookrail availability --service svc_... --from ... --to ... --explain --json bookrail bookings create --service svc_... --start ... --customer-email ... --json bookrail bookings get bk_... --json # close the loop on every write ``` Through MCP the same sequence is `bookrail_project_info`, `bookrail_examples`, `bookrail_config_validate`, `bookrail_config_push` with `dry_run: true`, then with `dry_run: false` and `confirm: true`, `bookrail_objects_list`, `bookrail_availability`, `bookrail_booking_create`, `bookrail_booking_get`. The full list of tools is on the [MCP page](/docs/mcp/). ## Output conventions Every CLI command accepts `--json` and prints one envelope: ```json { "ok": true, "environment": "test", "data": {}, "next_steps": ["..."] } ``` and on failure ```json { "ok": false, "environment": "test", "error": { "code": "...", "message": "...", "param": "...", "doc_url": "...", "fix": "..." } } ``` Act on `fix`. It is an instruction, not a diagnosis. Colour is off whenever stdout is not a terminal, and always off with `--json`. Two safety rules are enforced rather than advised. The environment is `test` unless `--live` is typed, and a `sk_live_` key used without it is refused before any request leaves the process. Every destructive command needs `--yes`; through MCP, every irreversible tool returns a preview and `requires_confirmation: true` until it is called again with `confirm: true`. ## Exit codes | Code | Meaning | What to do | | --- | --- | --- | | `0` | Success. | Read `data`, then `next_steps`. | | `1` | User or configuration error. | Fix the input named by `error.param`. | | `2` | Authentication. | The key is missing, wrong or for the other environment. | | `3` | Network or service. | The API was unreachable or answered 5xx. Retrying is safe: every `POST` carries an `Idempotency-Key`. | | `4` | Conflict. | The state changed underneath. Re-read, then decide. | ## The errors you will actually hit | Code | What it means | The fix | | --- | --- | --- | | `slot_unavailable` | The capacity went to somebody else between the answer and the booking. | Run `availability --explain` on the same window and take another instant. | | `start_not_on_grid` | The service defines `slotInterval` or `alignTo` and this instant is not on the grid. | Take a `start` from the availability answer, never a rounded clock time. | | `hold_not_active` | The hold expired, was released, or is already a booking. | `bookrail holds get hold_...` says which. Create a new hold. | | `idempotency_key_reused` | Same key, different body. | Use a new key, or send the original body. | | `idempotency_key_in_progress` | The first request with this key has not finished. | Wait and retry the same key: it will replay the first answer. | | `invalid_transition` | The booking is not in a state where that action is legal. | `bookings get` for the current status, then the transition the matrix allows. | | `timezone_missing` | A candidate resource has no time zone, on its schedule or on its location. | Give the schedule a `timezone`, or the location one. | | `not_yet_supported` | The field exists in the contract and not in this build, for example `payment.mode` other than `none`. | Drop the field. | | `live_key_without_live` | A `sk_live_` key without `--live`. | Add `--live`, deliberately. | The complete catalogue is on the [Errors page](/docs/errors/), and every error the API returns carries its own `doc_url`. ## End to end Ten commands, from nothing to a booking and back. Replace the ids with the ones your own `push` prints. ```bash # The public API is the default, so there is nothing to point the CLI at. npx bookrail login --token sk_test_... mkdir club && cd club npx bookrail init --template padel npx bookrail push --dry-run --json # read data.plan npx bookrail push --json npx bookrail diff --json # data.has_changes must be false SVC=$(npx bookrail services list --json | jq -r '.data.data[0].id') npx bookrail availability --service "$SVC" \ --from 2026-09-08T08:00:00+02:00 --to 2026-09-08T20:00:00+02:00 --json npx bookrail holds create --service "$SVC" \ --start 2026-09-08T09:00:00+02:00 --ttl 10m --customer-email anna@example.com --json npx bookrail bookings create --service "$SVC" \ --start 2026-09-08T09:00:00+02:00 --hold hold_... --customer-email anna@example.com --json npx bookrail bookings get bk_... --json ``` That runs against `https://api.bookrail.dev`, which is where the CLI goes by default. To point it at an instance of your own instead, set `BOOKRAIL_API_URL` (for example `export BOOKRAIL_API_URL=http://127.0.0.1:3000` for a local `pnpm dev`), or pass `--api-url` to one command, or store it once with `bookrail login --api-url `. Two things about that script are not decoration. `availability` returns `start` values that `bookings create` takes verbatim, so no instant is ever built by rounding a clock. And a bare date such as `2026-09-08` is refused by the CLI before the request leaves, because midnight is not the same instant in every time zone. The dates in it are fixed. Move them forward when you run it: the padel template ships a `maxAdvanceDays` of 14, so a start further out than that is refused by the booking window and not by a bug. ## Modelling a business onto the model Four questions, in this order. 1. **What is sold?** That is a Service, and it needs exactly one duration form. 2. **What has to be free for it to happen?** Those are Resources, and the Service's requirements. If several must be free at once, list several requirements. 3. **How many at once?** That is `capacity` on the resource and `quantity` on the booking. If the count lives on one thing, the seats of a class, put the capacity there and make the other requirements `consumes: "whole"`. 4. **What are the rules about money and time?** That is a Policy. Do not model a slot. Slots are computed, never stored. --- # CLI Source: https://bookrail.dev/docs/cli/ Every block on this page is the output of `--help` of the compiled CLI, captured at build time. If a flag is here, this build has it. Install nothing: `npx bookrail `. Every command takes `--json` and prints `{ ok, environment, data, error?, next_steps? }`. The default environment is test. ``` Usage: bookrail [options] [command] Bookrail: booking infrastructure as code. Describe locations, schedules, resources, groups, policies and services in bookrail.config.ts, then push them. Every command takes --json and prints { ok, environment, data, error?, next_steps? }. Exit codes: 0 success, 1 user or config error, 2 authentication, 3 network or service, 4 conflict. Options: -V, --version Print the CLI version and exit. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. Implied when stdout is not a terminal. --api-url Base URL of the API. Defaults to BOOKRAIL_API_URL, then the stored one. --timeout Per-request timeout. Default 30. -h, --help display help for command Commands: login [options] Store an API key for its environment. logout [options] Forget the stored key for this environment. whoami [options] Show which key, environment and API this invocation would use. version [options] Print the CLI version, the API version it asks for, and the Node version. env [options] Print the environment variables to set for this environment. init [options] Write bookrail.config.ts (and .env.example) from a vertical template. push [options] Make the project match bookrail.config.ts. pull [options] Write the current project out as a bookrail.config.ts. diff [options] Show what push would change, without changing anything. locations [options] Create, read, update and delete locations. resources [options] Create, read, update and delete resources. resource_groups [options] Create, read, update and delete resource_groups. schedules [options] Create, read, update and delete schedules. services [options] Create, read, update and delete services. policies [options] Create, read, update and delete policies. customers [options] Create, read, update and delete customers. availability [options] Ask what is bookable, and why an instant is not. holds [options] Take capacity for a few minutes, or give it back. bookings [options] Create, read and move bookings through their life cycle. webhooks [options] Register endpoints, inspect deliveries, and watch events arrive. events [options] Read the event log, and follow it. doctor [options] Check credentials, permissions, environment, reachability, version and config. schema [options] [entity] Print the JSON Schema of the configuration, or of one of its collections. examples [options] [vertical] Print a complete, working model of a vertical and the calls that follow it. docs [options] [topic] Print a page of the documentation bundled with this CLI, offline. mcp [options] Configure the Bookrail MCP server in a coding agent. logs [options] (not in this build) Request log, with --follow. requests [options] (not in this build) get REQUEST_ID. keys [options] (not in this build) list | create | revoke. projects [options] (not in this build) list | create | use. dev [options] (not in this build) Run the engine locally with a mini dashboard. migrate [options] (not in this build) Import from csv, json, Calendly, Acuity. upgrade [options] (not in this build) Update the CLI in place. help [command] display help for command ``` ## bookrail login ``` Usage: bookrail login [options] Store an API key for its environment. Options: --token The secret key, or `-` to read it from standard input. Without it, and with a terminal, you are asked. --api-url Store a non-default API base URL alongside the key. --skip-verification Do not call the API to check the key before storing it. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --timeout Per-request timeout in seconds. -h, --help display help for command Needs: a sk_test_ or sk_live_ secret key, from --token or from the terminal. Returns: the environment, the masked key, and where it was stored (mode 600). Next: `bookrail whoami`, then `bookrail init`. ``` ## bookrail logout ``` Usage: bookrail logout [options] Forget the stored key for this environment. Options: --all Remove the whole credentials file, both environments. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Returns: which key was removed and from which file. ``` ## bookrail whoami ``` Usage: bookrail whoami [options] Show which key, environment and API this invocation would use. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Needs: a stored key or BOOKRAIL_SECRET_KEY. Returns: environment, masked key, key source, API URL and version. Fails with exit 2 when the key is missing, wrong or refused. ``` ## bookrail env ``` Usage: bookrail env [options] Print the environment variables to set for this environment. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command The key is masked: read the real one from the credentials file. ``` ## bookrail version ``` Usage: bookrail version [options] Print the CLI version, the API version it asks for, and the Node version. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command ``` ## bookrail init ``` Usage: bookrail init [options] Write bookrail.config.ts (and .env.example) from a vertical template. Options: --template Vertical to start from. Default: empty. --framework Also write a minimal client for this framework. --dir Directory to write into. Default: the working directory. --project Override the `project` field of the template. --force Overwrite files that already exist. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Templates: salon, padel, gym, rental, restaurant, clinic, coworking, tours, tutoring, empty. Frameworks: nextjs, nuxt, sveltekit, laravel, rails, django, expo, none (non-none also writes bookrail.ts, a minimal client). Needs: nothing. It never contacts the API and never prompts. Returns: the files written. Next: `bookrail push --dry-run`. ``` ## bookrail push ``` Usage: bookrail push [options] Make the project match bookrail.config.ts. Options: --config Path to the configuration file. --dry-run Compute and print the plan; change nothing. --yes Accept the deletions in the plan. --adopt Take over an existing object under this logical id. Repeatable. (default: []) --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Needs: a key, and a bookrail.config.* in the working directory (or --config). Returns: the plan (create / update / delete / unchanged) and what was applied. Objects with no metadata.config_id are never touched; they are listed as unmanaged. Deletions require --yes. Runs against the test environment unless --live is given. The environment is in every output. --adopt kind:config_id=remote_id makes one existing object managed, by stamping metadata.config_id on it. Never by name: names are not unique, and adopting the wrong "Court 1" would write one court's hours onto another. Repeatable. Next: run `bookrail diff`. It must report no differences. ``` ## bookrail pull ``` Usage: bookrail pull [options] Write the current project out as a bookrail.config.ts. Options: --out Where to write. Default: bookrail.config.ts. --stdout Print the file instead of writing it. --force Overwrite an existing file. --adopt Stamp metadata.config_id on the unmanaged objects before writing. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Needs: a key. Returns: the file written, and the objects that carry no metadata.config_id. Refuses to overwrite an existing file without --force. --adopt also STAMPS metadata.config_id on every unmanaged object, with the logical id this command derived from its name, so the file it writes is one push will reconcile instead of duplicating. It is the only way this command writes anything. ``` ## bookrail diff ``` Usage: bookrail diff [options] Show what push would change, without changing anything. Options: --config Path to the configuration file. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Needs: a key and a configuration file. Returns: `has_changes` plus the same plan `push --dry-run` prints. Exit code stays 0 whether or not there are differences: branch on `has_changes`. ``` ## bookrail locations ``` Usage: bookrail locations [options] [command] Create, read, update and delete locations. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List locations, one cursor page at a time. get [options] Read one location by its prefixed id. create [options] Create one location. update [options] Update one location. Sub-lists are replaced wholesale. delete [options] Delete one location. Requires --yes. help [command] display help for command Sub-commands: list, get , create, update , delete . Nothing is expandable on this collection. Bodies come from --data '{"..."}', --file body.json (or --file - for stdin), or --set key=value. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail resources ``` Usage: bookrail resources [options] [command] Create, read, update and delete resources. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List resources, one cursor page at a time. get [options] Read one resource by its prefixed id. create [options] Create one resource. update [options] Update one resource. Sub-lists are replaced wholesale. delete [options] Delete one resource. Requires --yes. blocks [options] List the periods this resource is closed for. help [command] display help for command Sub-commands: list, get , create, update , delete . Expandable: schedule (repeat --expand). Bodies come from --data '{"..."}', --file body.json (or --file - for stdin), or --set key=value. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail resource_groups ``` Usage: bookrail resource_groups [options] [command] Create, read, update and delete resource_groups. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List resource_groups, one cursor page at a time. get [options] Read one resource group by its prefixed id. create [options] Create one resource group. update [options] Update one resource group. Sub-lists are replaced wholesale. delete [options] Delete one resource group. Requires --yes. help [command] display help for command Sub-commands: list, get , create, update , delete . Expandable: resources (repeat --expand). Bodies come from --data '{"..."}', --file body.json (or --file - for stdin), or --set key=value. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail schedules ``` Usage: bookrail schedules [options] [command] Create, read, update and delete schedules. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List schedules, one cursor page at a time. get [options] Read one schedule by its prefixed id. create [options] Create one schedule. update [options] Update one schedule. Sub-lists are replaced wholesale. delete [options] Delete one schedule. Requires --yes. help [command] display help for command Sub-commands: list, get , create, update , delete . Nothing is expandable on this collection. Bodies come from --data '{"..."}', --file body.json (or --file - for stdin), or --set key=value. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail services ``` Usage: bookrail services [options] [command] Create, read, update and delete services. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List services, one cursor page at a time. get [options] Read one service by its prefixed id. create [options] Create one service. update [options] Update one service. Sub-lists are replaced wholesale. delete [options] Delete one service. Requires --yes. help [command] display help for command Sub-commands: list, get , create, update , delete . Expandable: requirements (repeat --expand). Bodies come from --data '{"..."}', --file body.json (or --file - for stdin), or --set key=value. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail policies ``` Usage: bookrail policies [options] [command] Create, read, update and delete policies. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List policies, one cursor page at a time. get [options] Read one policy by its prefixed id. create [options] Create one policy. update [options] Update one policy. Sub-lists are replaced wholesale. delete [options] Delete one policy. Requires --yes. help [command] display help for command Sub-commands: list, get , create, update , delete . Nothing is expandable on this collection. Bodies come from --data '{"..."}', --file body.json (or --file - for stdin), or --set key=value. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail customers ``` Usage: bookrail customers [options] [command] Create, read, update and delete customers. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List customers, one cursor page at a time. get [options] Read one customer by its prefixed id. create [options] Create one customer. update [options] Update one customer. Sub-lists are replaced wholesale. delete [options] Delete one customer. Requires --yes. help [command] display help for command Sub-commands: list, get , create, update , delete . Nothing is expandable on this collection. Bodies come from --data '{"..."}', --file body.json (or --file - for stdin), or --set key=value. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail availability ``` Usage: bookrail availability [options] [command] Ask what is bookable, and why an instant is not. Options: --service The service to ask about. Required. --from Start of the window, e.g. 2026-09-08T00:00:00+02:00. Required. --to End of the window, exclusive. Required. --tz Time zone of the answer. Presentation only: it never moves the grid. --quantity Units per booking. Default: the service capacity_per_booking. --resource Restrict the candidate resources. Repeatable. (default: []) --customer Apply this customer’s limits. --granularity slots (default) or ranges, for free-duration services. --explain Say why every rejected instant was rejected. Max 7 days. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: next [options] The first bookable instant, searched up to 90 days ahead. check [options] Is this precise instant bookable, and if not, why. Needs: --service, --from and --to, each an ISO 8601 instant with an explicit offset. Returns: the slots (or ranges), with capacity, price and the concrete resource options. --explain adds one row per rejected instant and reason. It is capped at 7 days. A window may not span more than 90 days. Runs against the test environment unless --live is given. The environment is in every output. Next: `bookrail holds create` on a start you got here, or `bookrail bookings create`. Sub-commands: next (the first bookable instant), check (one precise instant). ``` ## bookrail holds ``` Usage: bookrail holds [options] [command] Take capacity for a few minutes, or give it back. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: create [options] Hold capacity on one slot. get [options] Read one hold: is it still alive, until when, and what it became. release [options] Give the capacity back before the hold expires. help [command] display help for command Sub-commands: create, get , release . A hold expires by itself (policy.hold_duration_seconds, 10 minutes by default, 30 max). Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail bookings ``` Usage: bookrail bookings [options] [command] Create, read and move bookings through their life cycle. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: create [options] Book one slot, or convert a hold. get [options] Read one booking, with its allocations. list [options] List bookings, filtered and paginated. confirm [options] Move the booking to its confirm state. check-in [options] Move the booking to its check_in state. complete [options] Move the booking to its complete state. no-show [options] Move the booking to its no_show state. cancel [options] Cancel a booking and compute the refund its policy promises. reschedule [options] Move a booking to another instant, atomically. help [command] display help for command Sub-commands: create, get , list, confirm , cancel , reschedule , check-in , no-show , complete . There is no `update`: a booking changes by an action, never by a field edit. Expandable on get and list: customer, allocations.resource. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail webhooks ``` Usage: bookrail webhooks [options] [command] Register endpoints, inspect deliveries, and watch events arrive. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List the endpoints of this project and environment. get [options] Read one endpoint. The secret is never in the answer. create [options] Register an endpoint and print its signing secret, once. update [options] Change the URL, the subscriptions, or the status of an endpoint. delete [options] Delete an endpoint and its whole delivery log. Requires --yes. test [options] Send a synthetic webhook.test delivery, now, and report what came back. deliveries [options] The delivery log of one endpoint, newest first. retry [options] Queue a delivery again, with a fresh retry ladder. listen [options] Receive deliveries on a local port, or follow the event log. help [command] display help for command Sub-commands: list, get , create, update , delete , test , deliveries , retry , listen. The signing secret is shown once, by create, and by nothing else ever. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail events ``` Usage: bookrail events [options] [command] Read the event log, and follow it. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: list [options] List events, oldest first, and optionally keep following. get [options] Read one event by id. help [command] display help for command Sub-commands: list, get . The log is read-only and ordered by (txid, seq); the public cursor is an event id. A row becomes visible only once the transaction that wrote it has finished, which is what makes the cursor safe: nothing ever appears below a position already passed. Runs against the test environment unless --live is given. The environment is in every output. ``` ## bookrail doctor ``` Usage: bookrail doctor [options] Check credentials, permissions, environment, reachability, version and config. Options: --config Configuration file to validate. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Needs: nothing. Every problem is a check, never an exception. Returns: one { name, status, message, fix } per check, and a summary. Exits 1 when at least one check failed, 0 otherwise. ``` ## bookrail schema ``` Usage: bookrail schema [options] [entity] Print the JSON Schema of the configuration, or of one of its collections. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Names: config, locations, schedules, resources, resourceGroups, policies, services. Without one, the list is printed. ``` ## bookrail examples ``` Usage: bookrail examples [options] [vertical] Print a complete, working model of a vertical and the calls that follow it. Options: --framework Show the calls in TypeScript instead of curl. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Verticals: salon, padel, gym, rental, restaurant, clinic, coworking, tours, tutoring, empty. ``` ## bookrail docs ``` Usage: bookrail docs [options] [topic] Print a page of the documentation bundled with this CLI, offline. Options: --markdown Print raw markdown. This is also the default. --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command ``` ## bookrail mcp ``` Usage: bookrail mcp [options] [command] Configure the Bookrail MCP server in a coding agent. Options: --json Print the structured envelope instead of a human table. --live Operate on the live environment. Without it everything is test. --non-interactive Never prompt; fail with a fix instead. --api-url Base URL of the API. --timeout Per-request timeout in seconds. -h, --help display help for command Commands: install [options] Write or update the bookrail entry in an MCP client configuration. help [command] display help for command The server itself is `npx @bookrail/mcp`; this command only writes the entry that starts it into the client you name. It never writes a key: the server reads the same credentials `bookrail login` stores. ``` --- # Rules an agent can rely on Source: https://bookrail.dev/docs/agents/ The rules this CLI is built to, so that an agent can drive it without guessing. 1. **Self-describing.** Every command's `--help` says what it does, what it needs, what it returns, and what to do next. 2. **Structured output always available.** Every command takes `--json`. Nothing has to be parsed out of human text. 3. **Errors say how to fix them.** Every error carries a `fix` field: an operative sentence, not a diagnosis. 4. **Idempotent and safe by default.** Every `POST` the CLI makes carries an `Idempotency-Key`. Destructive operations need `--yes`. 5. **Test and live separated and visible.** Every output declares its `environment`. The default is test; live needs `--live`, and a live key used without it is refused before any request leaves the process. 6. **No hidden interactivity.** Every prompt has a flag that replaces it. When stdout is not a terminal the CLI never asks: it fails with a `fix` instead of hanging. 7. **Verifiability.** Every action has a way to check its outcome: `bookrail diff` after a push, `bookrail doctor` when something is wrong, `bookrail get` after a write. ## Recommended order of operations ```bash bookrail doctor --json # what is configured, what is missing bookrail init --template --json # write bookrail.config.ts bookrail push --dry-run --json # read `data.plan` before applying bookrail push --json # apply bookrail diff --json # data.has_changes must be false bookrail services list --json # read back the svc_ ids bookrail availability --service svc_... --from ... --to ... --json # what is bookable bookrail availability --service svc_... --from ... --to ... --explain --json # and why not bookrail bookings create --service svc_... --start ... --customer-email ... --json bookrail bookings get bk_... --json # close the loop on every write ``` `bookrail schema config --json` gives the JSON Schema to write a config against. `bookrail examples --json` gives a complete, working model plus the calls that follow it. `bookrail docs --markdown` prints this documentation offline. ## Mapping a business onto the model Ask four questions, in this order: 1. **What is sold?** That is a Service, and it needs exactly one duration form. 2. **What has to be free for it to happen?** Those are Resources, and the Service's requirements. If several must be free at the same time, list several requirements. 3. **How many at once?** That is `capacity` on the resource, and `quantity` on the booking. If the count lives on one thing (the seats of a class) put the capacity there and make the other requirements `consumes: "whole"`. 4. **What are the rules about money and time?** That is a Policy. Do not model a "slot": slots are computed, never stored. ## Things that will bite - Ids in a config are **logical**. The `svc_...` identifier only exists after a push. - A push never touches an object with no `metadata.config_id`. If you created objects through the API, `bookrail pull` will show them but pushing that file will create copies. To take them over, use `bookrail pull --adopt` (stamps all of them) or `bookrail push --adopt :=` (one, named by its remote id). Nothing is ever adopted by name: names are not unique. - `align_to` and `slot_interval` decide which instants exist. A service with no grid accepts any instant inside the opening hours. - Amounts are integers in the minor unit: `3000` is 30.00 EUR. - Instants in must carry an explicit offset; instants out are UTC. A bare date is refused by the CLI before the request: midnight is not the same instant in every time zone. - `bookrail webhooks test` exits 0 even when the endpoint answers 500. The delivery happened, and that is the answer: branch on `data.status` (`succeeded` / `failed`). The same rule applies to `bookrail diff` and `data.has_changes`. - `--follow` and `webhooks listen` need `--max` or `--duration` when combined with `--json`: one envelope cannot be printed by a loop that never ends. --- # MCP Source: https://bookrail.dev/docs/mcp/ The Bookrail MCP server exposes 36 tools. This page is generated from the server itself: the build starts it over stdio, asks `tools/list`, and writes both this page and [`/mcp/tools.json`](/mcp/tools.json), the same list with every input schema. ```bash npx bookrail mcp install --client claude-code # writes ./.mcp.json npx @bookrail/mcp # or run the server by hand, over stdio ``` The default environment is test. A tool refuses live unless the server was started with `BOOKRAIL_MCP_ALLOW_LIVE=1` and a live key. Irreversible tools return a preview until they are called again with `confirm: true`. | Tool | What it does | Annotations | | --- | --- | --- | | `bookrail_docs_search` | Searches the Bookrail documentation packaged with this server and returns the matching pages, ranked, each with the lines that matched. | read only, idempotent | | `bookrail_docs_get` | Returns one page of the Bookrail documentation as markdown, offline. With no `path`, lists the pages available. | read only, idempotent | | `bookrail_schema` | Returns the JSON Schema of `bookrail.config.ts`, or of one of its collections, generated from the same Zod schemas that validate a push, so a config written against it cannot be refused for a field that does not exist. | read only, idempotent | | `bookrail_examples` | Returns a complete, valid `bookrail.config.ts` for one vertical, plus the calls that follow it in order. | read only, idempotent | | `bookrail_edge_cases` | Returns the list of booking edge cases Bookrail handles, with how it handles each: simultaneous requests for the last seat, holds expiring mid-flow, schedule changes that orphan a booking, capacity reductions, overlapping buffers, bookings across midnight and across a clock change, splits across a group, blocks, pricing rules on a band that wraps midnight or on an hour that does not exist, and the pitfalls of driving the API. | read only, idempotent | | `bookrail_project_info` | Returns the project the configured key belongs to, the API URL and version, the key (masked) and where it came from, and whether the live environment is reachable from this server at all. | read only, idempotent | | `bookrail_doctor` | Runs every check the `bookrail doctor` command runs (Node version, credentials and their file permissions, environment against key prefix, API reachability, API version drift, authentication, project and project environment, configuration file validity) and returns each as ok / warn / fail with a `fix` sentence. | read only, idempotent | | `bookrail_config_validate` | Validates a configuration (locations, schedules, resources, resource groups, policies, services) against the same schema a push validates with, and cross-checks every logical reference (a service pointing at a group that the file does not declare, a resource pointing at a missing schedule). | read only, idempotent | | `bookrail_config_push` | Reconciles a configuration with the project: creates what is missing, updates what differs, deletes what the configuration no longer declares. Objects with no `metadata.config_id` were not created from a configuration and are never touched: they come back in `unmanaged`. | destructive, idempotent | | `bookrail_config_pull` | Returns the project as a configuration object, in the shape `bookrail.config.ts` declares. Read-only: nothing is written, on the project or on disk. | read only, idempotent | | `bookrail_availability` | Returns every bookable instant for a service between two instants, with duration, remaining capacity, price and the resource combinations that could serve it. | read only, idempotent | | `bookrail_availability_next` | Returns the first instant a service can be booked at, searched in 30-day windows up to 90 days ahead. | read only, idempotent | | `bookrail_availability_check` | Answers whether one precise instant is bookable, and when it is not, returns the structured reasons. | read only, idempotent | | `bookrail_explain_unavailable` | The "why not" of the availability engine for one instant: closed schedule, block, existing occupancy, buffer, booking window, grid alignment, capacity, policy. Each one comes back as a code, a message and, where it applies, the resource it came from. | read only, idempotent | | `bookrail_hold_create` | Takes the capacity for an instant and keeps it for the policy's hold duration (ten minutes by default), so you can collect a payment or a confirmation without racing anyone else. | idempotent | | `bookrail_hold_get` | Returns one hold and, above all, its status: `active` (still convertible), `released`, `expired`, or `converted`, with `booking_id` when it became a booking. | read only, idempotent | | `bookrail_hold_release` | Releases a hold and frees the capacity immediately, instead of waiting for it to expire. | idempotent | | `bookrail_booking_create` | Creates a booking, optionally by converting a hold. The capacity is taken inside one database transaction, so two simultaneous requests for the last seat cannot both succeed: the loser gets `slot_unavailable` (409). | idempotent | | `bookrail_booking_get` | Returns one booking with its status, times, price, refund expectation, allocations and the transition scheduled for it. | read only, idempotent | | `bookrail_booking_list` | Lists bookings, filtered by customer, service, resource, status or time window, with cursor pagination. | read only, idempotent | | `bookrail_booking_transition` | Applies one of the four forward transitions of a booking: `confirm`, `check_in`, `complete`, `no_show`. Each is checked against the state matrix; an illegal transition is a 409 that names the current status. | | | `bookrail_booking_cancel` | Cancels a booking, releases its capacity and computes the refund the frozen policy snapshot entitles the customer to. | destructive | | `bookrail_booking_reschedule` | Moves a booking to a new instant. The answer is the NEW booking; the old one becomes "rescheduled" and is reachable at `rescheduled_from_booking_id`. | | | `bookrail_objects_list` | Lists the objects of one collection: locations, resources, resource_groups, schedules, services, policies, customers. | read only, idempotent | | `bookrail_resource_blocks` | Lists the blocks on one resource: the periods it is closed for (holidays, maintenance) without the schedule saying so. | read only, idempotent | | `bookrail_object_get` | Returns one object of one collection by its prefixed id. | read only, idempotent | | `bookrail_object_create` | Creates one object through the API, bypassing the configuration file. | | | `bookrail_object_update` | Patches one object. Only the fields you send are changed, except the sub-lists (`rules`, `resource_ids`, `requirements`), where the set you send REPLACES the whole set. | idempotent | | `bookrail_object_delete` | Deletes one object. Resources and services are soft-deleted (they disappear from reads); everything else is removed. References from other objects become null. | destructive | | `bookrail_events_list` | Lists the events of the project (booking.created, booking.cancelled, booking.orphaned, hold.expired and the rest), newest page first, in a total order that is safe to page through. | read only, idempotent | | `bookrail_event_get` | Returns one event with its full payload: the object as it is after the change, and the previous version where there is one. | read only, idempotent | | `bookrail_webhook_list` | Lists the webhook endpoints of the project with their URL, subscribed events and status. | read only, idempotent | | `bookrail_webhook_create` | Registers an endpoint to deliver events to, and returns its signing secret. That is the only time it is shown: no other call ever returns it again, not even an idempotent replay. | | | `bookrail_webhook_test` | Delivers a synthetic event to one endpoint, synchronously, and returns what the endpoint answered. | idempotent | | `bookrail_webhook_deliveries` | Lists the deliveries attempted to one endpoint, with attempt number, response status, duration, error and when the next retry is due. | read only, idempotent | | `bookrail_webhook_delete` | Removes an endpoint. Events stop being delivered to it immediately, and its signing secret is gone: re-creating the endpoint gives a new one, and every handler verifying the old secret breaks. | destructive | ## Every tool ### bookrail_docs_search Searches the Bookrail documentation packaged with this server and returns the matching pages, ranked, each with the lines that matched. Use it whenever you are about to guess: how availability is computed, what a config field means, what an error code means, how holds and bookings relate. Returns: `{ query, hits: [{ topic, title, url, score, excerpts }] }`. Next: `bookrail_docs_get` with the `topic` of the best hit, for the whole page. Arguments: `query` (required), `limit` ### bookrail_docs_get Returns one page of the Bookrail documentation as markdown, offline. With no `path`, lists the pages available. Use it after `bookrail_docs_search`, or directly when you know the page: `getting-started`, `config`, `entities`, `api`, `errors`, `timezones`, `agents`. Returns: `{ topic, title, markdown, url }`, or `{ topics: [...] }` when `path` is omitted. Next: `bookrail_schema` for the exact shape of the configuration, then `bookrail_config_validate`. Arguments: `path` ### bookrail_schema Returns the JSON Schema of `bookrail.config.ts`, or of one of its collections, generated from the same Zod schemas that validate a push, so a config written against it cannot be refused for a field that does not exist. Use it before writing or editing a configuration, and whenever `bookrail_config_validate` reported a field you do not recognise. Entities: config, locations, schedules, resources, resourceGroups, policies, services. Returns: the JSON Schema object, or `{ schemas: [...] }` when `entity` is omitted. Next: `bookrail_config_validate`, then `bookrail_config_push` with dry_run: true. Arguments: `entity` ### bookrail_examples Returns a complete, valid `bookrail.config.ts` for one vertical, plus the calls that follow it in order. Use it as the starting point for modelling a business: pick the closest vertical, then edit it. It is faster and safer than writing a configuration from the schema alone. Verticals: salon, padel, gym, rental, restaurant, clinic, coworking, tours, tutoring, empty. Returns: `{ vertical, summary, config, config_file, calls }`. `config` is the object to pass to `bookrail_config_push`. Next: `bookrail_config_validate` with your edited config, then `bookrail_config_push` with dry_run: true. Arguments: `vertical`, `framework` ### bookrail_edge_cases Returns the list of booking edge cases Bookrail handles, with how it handles each: simultaneous requests for the last seat, holds expiring mid-flow, schedule changes that orphan a booking, capacity reductions, overlapping buffers, bookings across midnight and across a clock change, splits across a group, blocks, pricing rules on a band that wraps midnight or on an hour that does not exist, and the pitfalls of driving the API. Use it before building anything yourself: most of what looks like "a special case in my business" is already a tested case here. Topics: concurrency, daylight-saving, schedules, pricing, pitfalls, errors. Omit to get them all. Returns: `{ topics: [{ topic, title, source_page, markdown }] }`. Next: `bookrail_docs_get` for the whole page a topic came from. Arguments: `topic` ### bookrail_project_info Returns the project the configured key belongs to, the API URL and version, the key (masked) and where it came from, and whether the live environment is reachable from this server at all. Use it first, before any other call that touches data: it proves the key works and names the project you are about to change. Returns: `{ environment, project: { id, name, default_timezone, default_currency }, api_key: { id, scopes, tenant_id }, api_url, api_version_served, live_allowed }`. Next: `bookrail_doctor` if anything looks wrong; `bookrail_config_pull` to see what the project already contains. Arguments: `environment` ### bookrail_doctor Runs every check the `bookrail doctor` command runs (Node version, credentials and their file permissions, environment against key prefix, API reachability, API version drift, authentication, project and project environment, configuration file validity) and returns each as ok / warn / fail with a `fix` sentence. Use it when a call failed and you do not know why, or before starting work in a new project directory. Returns: `{ checks: [{ name, status, message, fix? }], summary: { ok, warn, fail } }`. `ok: false` is never returned for a failed check: read `summary.fail`. Next: act on the `fix` of every failing check, then call `bookrail_project_info`. Arguments: `environment`, `config_path` ### bookrail_config_validate Validates a configuration (locations, schedules, resources, resource groups, policies, services) against the same schema a push validates with, and cross-checks every logical reference (a service pointing at a group that the file does not declare, a resource pointing at a missing schedule). Use it after writing or editing a configuration and before every push. It touches no network and needs no key. Returns: `{ valid, issues: [{ path, message }], counts }`. `path` is the position inside the configuration, e.g. `services[1].requirements[0].group`. Next: `bookrail_config_push` with dry_run: true. Arguments: `config`, `config_path` ### bookrail_config_push Reconciles a configuration with the project: creates what is missing, updates what differs, deletes what the configuration no longer declares. Objects with no `metadata.config_id` were not created from a configuration and are never touched: they come back in `unmanaged`. Use it after `bookrail_config_validate`. Call it first with `dry_run: true` (the default) to read the plan; then with `dry_run: false` and `confirm: true` to apply. Safety: `dry_run: false` requires `confirm: true`, and so does any plan that contains a deletion. Without it the tool returns the plan and `requires_confirmation: true` and changes nothing. Returns: `{ plan: [{ action, kind, config_id, remote_id, name, changes }], counts, unmanaged, applied }`. Next: `bookrail_objects_list` with kind "services" to read back the `svc_` ids, then `bookrail_availability`. Arguments: `environment`, `config`, `config_path`, `dry_run`, `confirm` ### bookrail_config_pull Returns the project as a configuration object, in the shape `bookrail.config.ts` declares. Read-only: nothing is written, on the project or on disk. Use it to discover what a project already contains before changing anything, or to start a configuration from a project that was built through the API. Returns: `{ config, written: null, stamped: [], adopted }`. `adopted` lists objects with no `metadata.config_id`: pushing this configuration would create copies of them, so take them over with the CLI (`bookrail pull --adopt`) before pushing. Next: `bookrail_config_validate` on the returned `config`, then `bookrail_config_push` with dry_run: true. Arguments: `environment` ### bookrail_availability Returns every bookable instant for a service between two instants, with duration, remaining capacity, price and the resource combinations that could serve it. Use it before creating a hold or a booking. With `explain: true` it also returns, for every candidate instant that is NOT bookable, the structured reasons why, which is the fastest way to understand a model that is not doing what you expect. Window: at most 90 days, 7 with `explain`. Returns: `{ service_id, timezone, granularity, slots: [{ start, end, duration_minutes, available_capacity, price, price_rule, resource_options }], next_available, explain? }`. Instants out are UTC. `price` is the price of that slot, not necessarily the flat price of the service: if the service carries `pricing_rules`, the first rule whose `when` matches decides, and `price_rule` is `{ index, label }` naming it, or null when the flat price applied. It is the price the booking will freeze. Next: `bookrail_hold_create` to take the capacity for a few minutes, or `bookrail_booking_create` to book directly. Arguments: `environment`, `service_id` (required), `from` (required), `to` (required), `quantity`, `timezone`, `resource_ids`, `customer_id`, `granularity`, `explain` ### bookrail_availability_next Returns the first instant a service can be booked at, searched in 30-day windows up to 90 days ahead. Use it when you need *an* instant rather than a window: a smoke test after a push, or the default a user is offered. Returns: `{ next_available, slot, searched_through, timezone }`. `next_available` is null when nothing is bookable in 90 days. Next: `bookrail_availability_check` on that instant, then `bookrail_hold_create` or `bookrail_booking_create`. Arguments: `environment`, `service_id` (required), `from`, `quantity`, `timezone` ### bookrail_availability_check Answers whether one precise instant is bookable, and when it is not, returns the structured reasons. Use it right before booking an instant you got from somewhere else (a UI, a cache, a user), and to close the loop after a configuration change. Returns: `{ available, available_capacity, price, resource_options, reasons? }`. Next: `bookrail_hold_create` if available; `bookrail_explain_unavailable` or `bookrail_availability` with explain: true if not. Arguments: `environment`, `service_id` (required), `start` (required), `duration_minutes`, `quantity`, `resource_ids` ### bookrail_explain_unavailable The "why not" of the availability engine for one instant: closed schedule, block, existing occupancy, buffer, booking window, grid alignment, capacity, policy. Each one comes back as a code, a message and, where it applies, the resource it came from. Use it whenever an instant you expected to be bookable is not. It is the same computation `bookrail_availability_check` reports and the same one `explain: true` reports over a window, narrowed to one instant so the answer is short. Returns: `{ available, reasons: [{ code, message, resource_id? }] }`. Next: fix the model (`bookrail_config_push`) or pick another instant (`bookrail_availability_next`). Arguments: `environment`, `service_id` (required), `start` (required), `duration_minutes`, `quantity` ### bookrail_hold_create Takes the capacity for an instant and keeps it for the policy's hold duration (ten minutes by default), so you can collect a payment or a confirmation without racing anyone else. Use it in any flow where something happens between choosing a slot and committing to it. Skip it and call `bookrail_booking_create` directly when nothing happens in between. Returns: `{ id, status, start, end, expires_at, quantity, price, allocations }`. Next: `bookrail_booking_create` with `hold_id` to convert it, or `bookrail_hold_release` to give it back. Arguments: `environment`, `service_id` (required), `start` (required), `duration_minutes`, `quantity`, `resource_ids`, `ttl`, `metadata`, `customer_id`, `customer_email`, `customer_name`, `customer_phone`, `customer_external_id` ### bookrail_hold_get Returns one hold and, above all, its status: `active` (still convertible), `released`, `expired`, or `converted`, with `booking_id` when it became a booking. Use it when a flow was interrupted and you do not know whether the hold you took is still yours, and before retrying a conversion that failed: a hold that is `expired` will never convert, and the answer is to take a new one. Returns: `{ id, status, service_id, start, end, expires_at, booking_id, allocations }`. `price` is null on a read: a hold has no stored price, only a booking freezes one. Next: `bookrail_booking_create` with `hold_id` while it is active; `bookrail_availability` once it is not. Arguments: `environment`, `hold_id` (required) ### bookrail_hold_release Releases a hold and frees the capacity immediately, instead of waiting for it to expire. Use it as soon as a flow is abandoned. It needs no confirmation: `DELETE /v1/holds/{id}` is idempotent and releasing is the intended end of a hold's life. A hold already converted into a booking answers 409 `hold_not_active`. Returns: `{ id, deleted: true }`. Next: nothing. Releasing twice is a success, not an error. Arguments: `environment`, `hold_id` (required) ### bookrail_booking_create Creates a booking, optionally by converting a hold. The capacity is taken inside one database transaction, so two simultaneous requests for the last seat cannot both succeed: the loser gets `slot_unavailable` (409). Use it after `bookrail_availability` or `bookrail_availability_check`. Pass `hold_id` when you held the slot first: converting a hold cannot fail for capacity. Returns: the booking `{ id, status, start, end, price, allocations, next_transition }`. Next: `bookrail_booking_get` to close the loop; `bookrail_booking_confirm` when the status is "pending". Arguments: `environment`, `service_id` (required), `start` (required), `duration_minutes`, `quantity`, `hold_id`, `resource_ids`, `notes`, `source`, `metadata`, `customer_id`, `customer_email`, `customer_name`, `customer_phone`, `customer_external_id` ### bookrail_booking_get Returns one booking with its status, times, price, refund expectation, allocations and the transition scheduled for it. Use it after every write, to close the loop on what actually happened. Returns: the booking object. Next: the transition the `next_transition` field names, or `bookrail_booking_cancel`. Arguments: `environment`, `booking_id` (required), `expand` ### bookrail_booking_list Lists bookings, filtered by customer, service, resource, status or time window, with cursor pagination. Use it to answer "what is on the calendar" and to find a booking whose id you do not have. Returns: `{ data: [...], has_more, next_cursor }`. Pass `next_cursor` back as `starting_after` for the next page. Next: `bookrail_booking_get` on one of them. Arguments: `environment`, `customer_id`, `service_id`, `resource_id`, `status`, `from`, `to`, `limit`, `starting_after` ### bookrail_booking_transition Applies one of the four forward transitions of a booking: `confirm`, `check_in`, `complete`, `no_show`. Each is checked against the state matrix; an illegal transition is a 409 that names the current status. Use it to record what happened. `no_show` is a fact being recorded, not a cancellation: to cancel, use `bookrail_booking_cancel`. Returns: the booking after the transition. Next: `bookrail_booking_get`, or the transition its `next_transition` names. Arguments: `environment`, `booking_id` (required), `action` (required) ### bookrail_booking_cancel Cancels a booking, releases its capacity and computes the refund the frozen policy snapshot entitles the customer to. IRREVERSIBLE: a cancelled booking does not come back, it is created again. So without `confirm: true` this tool returns the booking as it stands today plus `requires_confirmation: true`, and changes nothing. Returns: the cancelled booking, with `refund_percent` and `refund_amount_expected`. Payments do not exist yet, so the refund is an expectation, not a movement. Next: `bookrail_booking_get` to read it back. Arguments: `environment`, `booking_id` (required), `reason`, `by`, `refund_percent`, `confirm` ### bookrail_booking_reschedule Moves a booking to a new instant. The answer is the NEW booking; the old one becomes "rescheduled" and is reachable at `rescheduled_from_booking_id`. Use it instead of cancel-and-rebook: rescheduling keeps the link, counts against `max_reschedules`, and applies the reschedule ladder of the policy rather than the cancellation one. Returns: the new booking. Next: `bookrail_booking_get` on the returned `id`. Arguments: `environment`, `booking_id` (required), `start` (required), `resource_ids` ### bookrail_objects_list Lists the objects of one collection: locations, resources, resource_groups, schedules, services, policies, customers. Use it after `bookrail_config_push` to read back the ids the API assigned (`svc_...`, `res_...`): a configuration uses logical ids, and the prefixed ids only exist after a push. `metadata.config_id` on each object is the logical id it came from. Returns: `{ data: [...], has_more, next_cursor }`. Next: `bookrail_availability` with the `svc_` id of a service. Arguments: `environment`, `kind` (required), `limit`, `starting_after`, `all`, `expand` ### bookrail_resource_blocks Lists the blocks on one resource: the periods it is closed for (holidays, maintenance) without the schedule saying so. Use it to find the `blk_...` of a block you want to lift, which is the only way to lift one, and to explain why a resource is unavailable on a day its schedule says it is open. Without `from`/`to` it returns the blocks that have not finished yet. Returns: `{ data: [{ id, resource_id, from, to, reason, metadata }], has_more, next_cursor }`. Instants are UTC. Next: `bookrail_explain_unavailable` if a block is not what you expected; the unblock itself is `POST /v1/resources/{id}/unblock` with the `blk_...`, which no tool wraps yet. Arguments: `environment`, `resource_id` (required), `from`, `to`, `limit`, `starting_after`, `all` ### bookrail_object_get Returns one object of one collection by its prefixed id. Use it to close the loop after a write, and to read `metadata.config_id` to find out whether an object is managed by a configuration. Returns: the object. Next: `bookrail_object_update`, or `bookrail_config_push` if the object is managed by a configuration. Editing a managed object outside the file makes the next push undo the change. Arguments: `environment`, `kind` (required), `id` (required), `expand` ### bookrail_object_create Creates one object through the API, bypassing the configuration file. Prefer `bookrail_config_push`: an object created here carries no `metadata.config_id`, so a later push will never update or delete it, and it shows up as `unmanaged`. Use this for something genuinely outside the model: a customer, a one-off resource. The body uses the API field names (`location_id`, `schedule_id`), not the configuration ones (`location`, `schedule`). Call `bookrail_docs_get` with path "api" for the reference. Returns: the created object. Next: `bookrail_object_get` to read it back. Arguments: `environment`, `kind` (required), `data` (required) ### bookrail_object_update Patches one object. Only the fields you send are changed, except the sub-lists (`rules`, `resource_ids`, `requirements`), where the set you send REPLACES the whole set. If the object carries `metadata.config_id` it is managed by a configuration file, and the next `bookrail_config_push` will put it back the way the file describes. Change the file instead. Returns: the updated object. Next: `bookrail_object_get`, or `bookrail_availability` when the change could affect what is bookable. Arguments: `environment`, `kind` (required), `id` (required), `data` (required) ### bookrail_object_delete Deletes one object. Resources and services are soft-deleted (they disappear from reads); everything else is removed. References from other objects become null. IRREVERSIBLE: without `confirm: true` this tool returns the object as it stands today plus `requires_confirmation: true`, and deletes nothing. Read the preview, in particular `metadata.config_id`, which tells you whether a configuration file still declares it. Returns: `{ id, deleted: true }`. Next: `bookrail_config_push` with dry_run: true, to check the configuration and the project still agree. Arguments: `environment`, `kind` (required), `id` (required), `confirm` ### bookrail_events_list Lists the events of the project (booking.created, booking.cancelled, booking.orphaned, hold.expired and the rest), newest page first, in a total order that is safe to page through. Use it to find out what a call actually did, to check that a change produced the event you expected, and as the payload reference for a webhook handler: an event here is byte for byte what a delivery would have carried. Returns: `{ data: [{ id, type, occurred_at, actor, data: { object, previous } }], has_more, next_cursor }`. Next: `bookrail_event_get` for one of them; `bookrail_webhook_create` to receive them instead of polling. Arguments: `environment`, `type`, `object_id`, `from`, `to`, `limit`, `starting_after` ### bookrail_event_get Returns one event with its full payload: the object as it is after the change, and the previous version where there is one. Use it when an event id came from a webhook delivery or from `bookrail_events_list` and you need the whole body. Returns: the event object. Next: `bookrail_booking_get` on `data.object.id` if it is a booking event. Arguments: `environment`, `event_id` (required) ### bookrail_webhook_list Lists the webhook endpoints of the project with their URL, subscribed events and status. Use it before creating one, so you do not add a second endpoint for the same URL. Returns: `{ data: [{ id, url, events, status }], has_more, next_cursor }`. The signing secret is never returned here: it is shown only once, by `bookrail_webhook_create`. Next: `bookrail_webhook_test` to make one deliver now. Arguments: `environment`, `limit`, `starting_after` ### bookrail_webhook_create Registers an endpoint to deliver events to, and returns its signing secret. That is the only time it is shown: no other call ever returns it again, not even an idempotent replay. Use it when wiring an application to Bookrail. Store the secret in the application's environment immediately (BOOKRAIL_WEBHOOK_SECRET) and verify every `Bookrail-Signature` with it. On live only https is accepted; on test http is allowed on ports 80, 443 and 8080-8099. Returns: `{ id, url, events, status, secret }`. Next: `bookrail_webhook_test` to deliver a synthetic event and see what the endpoint answers. Arguments: `environment`, `url` (required), `events`, `description`, `metadata` ### bookrail_webhook_test Delivers a synthetic event to one endpoint, synchronously, and returns what the endpoint answered. Use it to check a handler end to end without creating a real booking. Returns: `{ status: "succeeded" | "failed", response_status, duration_ms, error? }`. `ok` stays true even when the endpoint answers 500: the delivery happened, and that IS the answer. Branch on `data.status`. Next: `bookrail_webhook_deliveries` to read the endpoint's log. Arguments: `environment`, `webhook_id` (required) ### bookrail_webhook_deliveries Lists the deliveries attempted to one endpoint, with attempt number, response status, duration, error and when the next retry is due. Use it when an endpoint is not receiving what you expect: it distinguishes "never sent" from "sent and refused". Returns: `{ data: [{ id, event_id, status, attempt, response_status, error, next_attempt_at }], has_more }`. Next: `bookrail_event_get` on a delivery's `event_id` to see what was sent. Arguments: `environment`, `webhook_id` (required), `status`, `event`, `limit` ### bookrail_webhook_delete Removes an endpoint. Events stop being delivered to it immediately, and its signing secret is gone: re-creating the endpoint gives a new one, and every handler verifying the old secret breaks. IRREVERSIBLE: without `confirm: true` the tool returns the endpoint as it stands and `requires_confirmation: true`, and removes nothing. Returns: `{ id, deleted: true }`. Next: `bookrail_webhook_list` to check what is left. Arguments: `environment`, `webhook_id` (required), `confirm` --- # Open source Source: https://bookrail.dev/docs/open-source/ ## The licence **Apache 2.0**, for the engine, the API, the SDKs, the components and the CLI. Confirmed on 7 September 2026, after weighing AGPL and the source available licences: Apache 2.0 gives the widest adoption, works inside a company without a lawyer, and carries a patent grant that MIT does not. Every package in the repository already declares it: `bookrail`, `@bookrail/mcp`, `@bookrail/node`, `@bookrail/webhook-signature`, and the internal `db`, `engine`, `api` and `shared`. ## Where the repository is Public since 10 September 2026, at [github.com/bookrail-dev/bookrail](https://github.com/bookrail-dev/bookrail). The organisation is `bookrail-dev` because the name `bookrail` on GitHub belongs to an inactive account. Bugs and questions go to the issue tracker of that repository, and the most useful issue you can open is one that names a case [The edge cases of booking](/docs/edge-cases/) does not cover: that page is the specification, so a missing case is a missing guarantee. Contributions arrive as pull requests, each with a test; [CONTRIBUTING.md](https://github.com/bookrail-dev/bookrail/blob/main/CONTRIBUTING.md) in the repository says how the suite is run. The four packages are on npm: [`bookrail`](https://www.npmjs.com/package/bookrail), [`@bookrail/node`](https://www.npmjs.com/package/@bookrail/node), [`@bookrail/mcp`](https://www.npmjs.com/package/@bookrail/mcp) and [`@bookrail/webhook-signature`](https://www.npmjs.com/package/@bookrail/webhook-signature). Everything the documentation shows with `npx` works from npm exactly as it works from a clone of the repository. ## What is open, and what is not The rule: **everything you need to make bookings work is open. Everything you need to run them at scale without thinking about it is the cloud.** Self hosting has to give a complete, honest product, otherwise the openness is a marketing claim. | Component | Open source | Cloud | | --- | --- | --- | | Availability and booking engine | Yes | Yes, the same code | | REST API, data model, Postgres migrations | Yes | Yes | | Job worker, webhook delivery | Yes | Yes | | SDKs, UI components, CLI, OpenAPI document | Yes | Yes | | Local mini dashboard | Yes | Not applicable | | Full dashboard: logs, availability simulator, analytics, team | No | Yes | | Billing, metering, multi account tenancy | No | Yes | | Managed notifications, hosted portal on your domain | No | Yes | | SSO, granular roles, advanced audit log | No | Yes | | Multiple regions, SLA, support, certifications | No | Yes | ## Why Nobody puts the core of their business behind a closed API from a company they have not heard of. With the code open you can read how concurrency and time zones are handled before you trust them, and you can host it yourself if we disappear. That is the whole argument; the rest follows from it. --- # The booking engine you should not have to write again Source: https://bookrail.dev/blog/the-booking-engine-you-should-not-have-to-write-again/ Every booking product is the same product underneath. A padel club, a dental practice, a co-working space, a boat rental, a photo studio: different words on the interface, the same eight problems under it. What is free right now. Who gets the slot when two people ask at the same instant. What happens to a slot when the clocks change. Which cancellation rule applies to a booking made before the rule changed. Whether the request you retried after a timeout created one booking or two. None of these problems is hard on its own. Together, and under real traffic, they are the reason booking systems get rewritten. The first version treats availability as a query and bookings as rows. The second version adds a lock. The third one discovers daylight saving time in production, usually in March, usually on a Sunday. The fourth one moves the cancellation policy out of the booking table and then cannot explain to a customer why their refund is different from what they were shown. So we built the layer once, in the open, and today the repository is public: [github.com/bookrail-dev/bookrail](https://github.com/bookrail-dev/bookrail), Apache 2.0. ## What Bookrail is, and what it is not Bookrail is booking infrastructure for developers. It is the layer under a booking product, not the product. You build the interface, the pricing and the brand. Bookrail answers what is free, takes the capacity without ever giving it twice, freezes the rules that applied at the moment of the booking, and emits a signed event for everything that happens. It is not a scheduling app, not a Calendly, not a plugin for a CMS. There is no end user interface at all. There is an HTTP API, a CLI, a TypeScript SDK, an MCP server for coding agents, and a PostgreSQL schema you can read. ## The guarantees, and where they live The decision that shaped everything else: the guarantees live in the database, not in the application. **Zero double bookings.** A resource with capacity 1 has a PostgreSQL exclusion constraint on its occupancies. A resource with capacity N has a trigger that checks the peak of overlapping occupancies. If the application has a bug, or if someone writes to the table with a SQL client, the constraint still holds. We do not trust this because it sounds right. The concurrency suite starts separate Node processes, each with its own connection, and fires simultaneous requests for the same slot: 40 in every test run, 200 per scenario before a release. Exactly one wins. Before a release we run the ten scenarios twenty rounds in a row, which is 40,000 requests. **Isolation: read committed, and the lock before the read.** We started from `SERIALIZABLE` because that is what the textbook says. We measured it. Under serializable isolation a transaction takes its snapshot at its first statement, so a transaction that then waits on an advisory lock keeps reading the state from before the lock holder committed. The lock orders the transactions, but it does not make the check after it authoritative. Under `read committed` every statement sees a fresh snapshot, so taking the advisory lock on the resource before reading its occupancies makes the check see exactly what the lock was taken for. Serializable is not wrong here, it is noisy. With the original order (read first, then lock) and three retries, 200 simultaneous requests on a capacity 15 slot left about one request in six with a `serialization_failure` instead of a clean `slot_unavailable`; it took about ten retries to clear them. With the lock first and read committed, nine runs out of forty under serializable still ended with requests lost to `serialization_failure`, and the read committed version lost none. The invariant is the constraint either way. The isolation level decides how many retries you pay for it. **Idempotent by construction.** Every `POST` accepts an `Idempotency-Key`. The key is taken with a unique constraint, not checked with a read. Twenty simultaneous requests with the same key produce one booking and nineteen identical responses. **Nothing changes the past.** The cancellation, reschedule and no show rules are copied into the booking when it is made, as a `policy_snapshot`. Change the policy tomorrow and the booking made today still knows its own terms. The price is frozen the same way, together with the pricing rule that produced it, so a booking can always say why it cost what it cost. **Time zones done properly.** Schedules live on a local clock. They are materialised into UTC day by day from the IANA database, so the day a clock changes has 23 or 25 hours and the rules still mean what they say. The suite runs the same schedule across the transitions in Rome, New York, Santiago, Sydney, Kolkata and Auckland, with the dates read from tzdata rather than typed in. **Availability that can explain itself.** Ask for availability with `explain` and each instant that is not free names the booking, the block, the buffer or the rule that took it. When you are staring at a calendar that says a room is busy and the room is empty, this is the difference between a five minute answer and an afternoon. ## Made to be driven by an agent Most booking products will be built, from now on, partly by coding agents. We took that literally. - Every CLI command has `--json`. - The MCP server exposes 36 tools, so an agent can create a project, describe a padel club, and make the first booking without leaving the editor. - The OpenAPI document is generated from the same Zod schemas that validate every request, and the API serves it without a key at `api.bookrail.dev/openapi.json`. - Every documentation page is also served as plain markdown. The quickstart, from a key to a confirmed booking, takes 21 seconds of machine time. Every command on that page was run against the production API and timed. ## The honest line The packages are `0.x`. What exists today: the engine, the API, the CLI, the MCP server and the SDK, all tested against a real PostgreSQL with no mocks, about 1,500 tests. The API is live. The documentation is live. What does not exist, and is documented as not existing: payments (any `payment.mode` other than `none` is a `400`), rate limiting, scope enforcement on API keys, a dashboard, browser SDKs and UI components. A test key is issued by a person. Write to hello@bookrail.dev and say what you are building. There is no sign up form because there is nothing behind one yet, and we would rather say so than build a form that pretends. ## What we would like from you Read the code. The transaction that takes capacity is one file. The list of things that go wrong in booking systems, what happens here for each of them, and the test that proves it, is on one page: [The edge cases of booking](https://bookrail.dev/docs/edge-cases/). If you find a case that is not on it, open an issue. That page is the spec. Francesco Paba, founder, Bookrail. hello@bookrail.dev