Skip to content

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

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.

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.

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.

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

Section titled “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.

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.

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

Section titled “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 is the whole of it.

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.

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.

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

Section titled “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.
  • 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.