# Partner Booking Rules API ## Overview Operators (venue groups) can attach **booking condition rules** to their venues. The platform enforces these rules, server-side and deterministically, on every partner **booking create** (`POST /venues/{compositeId}/booking`) and **booking amend** (`PUT` / `PATCH /venues/bookings/{bookingId}`). A booking that breaks a rule is rejected with HTTP 400 and an error code `BKRUL-N-001` ... `BKRUL-N-006` or `BKRUL-N-100`; one rule (`route_to_enquiry`, or an expression rule that sets `booking.type`) instead turns the booking into an **enquiry**. A third kind of expression rule does not gate a booking at all: a rule at the **`payment` hook** (moment `booking_payment`) changes the **amount payable** for a booking that already exists, when the platform computes its payment options (`GET /venues/bookings/{bookingId}/payment-options`) or creates its payment (`POST /venues/bookings/{bookingId}/payments`). See "Rules at the `payment` hook" below. This API is **read-only and advisory**. `GET /booking-rules` returns every booking condition rule that is **active** for the calling partner, with enough structure and documentation for an integration, in particular one written by an AI agent, to understand which conditions the platform enforces and to avoid hitting them. **The booking endpoints are authoritative**: the platform always re-validates every booking server side, whatever this API returned. **Which rules are returned.** Only rules that are `active` (inactive rules are never returned) and that apply to the calling partner, i.e. rules that have **no partner target at all**, or that name **your partner**, or that name **one of your partner categories** (same partner semantics the booking path uses). An expression rule can also aim at partners inside its `when` (facts `partner.id`, `partner.categoryIds`): it is returned only when, with your own partner facts, its `when` can still be true (see Confidentiality). Rules aimed at other partners are never returned; `appliesTo.partnerScope` only says *how* the rule is aimed at you through its partner targets (`all_partners`, `you`, `your_categories`). **Which venues are shown.** `appliesTo.venues[]` lists only the venues **you can see** (the venues `GET /venues` returns to you; if your partner mapping restricts an operator to some venues, only those). A rule restricted to venues none of which you can see is **not returned at all**: its restriction can never include a venue you book. `allVenues: false` with a non-empty `venues[]` therefore means "only these of **your** venues". **Who is "you".** The partner is identified from your credentials (OAuth2 client credentials). Bookable Agents (scope `agent:write`) send `X-Partner-Reference` to act for a partner, exactly as on the other partner endpoints. The response field `caller` repeats your own partner id and partner category ids: those are the values of the facts `partner.id` and `partner.categoryIds` that expression rules read. The categories you belong to differ per operator (an operator can also place you in a category through its own third-party records), so the value of `partner.categoryIds` for a rule is `caller.categoryIdsByOperator[rule.operatorId]`, not the global `caller.partnerCategoryIds`. ## Quick start ```bash # 1. Get a token (OAuth2 client credentials; the client must hold the venue:read permission). curl -s -X POST https://auth.bookabletech.com/oauth/token \ -H 'Content-Type: application/json' \ -d '{"grant_type":"client_credentials","client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET","audience":"api.bookabletech.com"}' # 2. Every active rule that applies to you (no parameter is required). curl -s 'https://api.bookabletech.com/booking-rules' \ -H 'Authorization: Bearer ACCESS_TOKEN' \ -H 'Accept: application/json' # 2b. Bookable Agents (scope agent:write) act for a partner by sending X-Partner-Reference. curl -s 'https://api.bookabletech.com/booking-rules' \ -H 'Authorization: Bearer ACCESS_TOKEN' \ -H 'X-Partner-Reference: acme-travel' ``` The answer is a JSON object `{generatedAt, caller, rules[], hooks[]}`; the named 200 examples of this operation show it in full. The filters are all optional and combined with AND (a `|` inside a venue id is sent as `%7C`): | I want | Request | |---|---| | Everything that applies to me | `GET /booking-rules` | | Only operator 17 | `GET /booking-rules?operatorId=17` | | Only the rules for the venue I am about to book (venue id, or the 4-segment product `compositeId`) | `GET /booking-rules?venueId=17%7CX9%7C9d3f0b6e7a1c4e58b2c4f1a60d7e8b93` | | Only deposit rules | `GET /booking-rules?effect=require_deposit` | | Only what is checked when I create a booking | `GET /booking-rules?hook=booking.create` | | Only what is checked when I amend a booking | `GET /booking-rules?hook=booking.amend` | | Only the rules that can change the amount payable for an existing booking | `GET /booking-rules?hook=payment` | | All filters together | `GET /booking-rules?operatorId=17&venueId=17%7CX9%7C9d3f0b6e7a1c4e58b2c4f1a60d7e8b93&effect=same_package&hook=booking.create` | ## Confidentiality Only rules that can apply to you are returned. This includes expression rules that target partners inside `when`: the platform evaluates the `partner.*` part of `when` with your own partner id and categories (every other fact unknown) and leaves the rule out when `when` can never be true for you. A rule aimed at several partners, you among them, is returned. For the rules that are returned, expression trees (`when`, `then`) and the operator-written text (`name`, `description`, `message`) are returned exactly as the operator stored them. They can therefore contain numeric ids (for example the other partner or partner category ids of a rule that also applies to you, compared with `partner.id`) or words chosen by the operator. The platform itself never adds the name of another partner or of another partner category to any field of the response: the generated `summary` names neither partners nor partner categories, and `appliesTo` only says *how* a rule is aimed at you (`partnerScope`). Do not treat the content of `when`, `then`, `name`, `description` or `message` as a statement about you or about any other partner. ## Integration guide for AI agents 1. **Fetch the rules.** Call `GET /booking-rules`. Optional filters: `operatorId` (one operator), `venueId` (only rules that apply to that venue), `effect`, `hook`. Read `caller` (your partner id and categories per operator), `rules[]` and `hooks[]`. The list is not paginated: you get the whole active set that applies to you. 2. **Keep the rules that can concern your booking**, using `rules[].appliesTo`: - venue: `allVenues` is true, or the first three segments of the 4-segment `compositeId` you book (`{operatorId}|{rmsSlug}|{venueResourceId}`) equal one of `venues[].id` (`venues[]` lists only venues you can see; a rule restricted to venues you cannot see is not returned). With `venueId` the server already did this. - product (booking type): `allProducts` is true, or the `slug` of the product you book (the `slug` field of the product in `GET /venues`) equals one of `products[].slug`. A rule with a product list never applies to a product that has no slug. - `conditions[]`, if any: **all** must hold (see "Field conditions"). - `partnerScope` already tells you the rule targets you (or everybody); you do not need to filter on it. - `whenApplied`: keep rules whose list contains the operation you are about to call (`booking_create` for create, `booking_amend` for PUT/PATCH). `booking_payment` is not an operation you send a request body to: it means the rule runs when the platform computes the amount payable for a booking that already exists (the payment options and the payment creation endpoints). Such a rule never gates your create or amend, and you **cannot** reproduce its facts (`payment.bands`) from the booking request alone: do NOT try to evaluate it client-side as a pass/fail gate. Use it to understand that the final balance may be the sum of the minimum-spend bands the booking crosses, and read the authoritative amount from `GET /venues/bookings/{bookingId}/payment-options` (operationId `getPaymentOptions` of the Payment API). 3. **Fixed effects** (every effect except `expression`): apply the exact semantics in the effects table below to the booking you intend to send, using `params`, `appliesTo.items` and the pre-order lines. 4. **Expression rules** (`effect: expression`): pick the rules whose `hook` matches the operation (`booking.create` for create, `booking.amend` for amend; the rules of hook `payment` are not evaluated by you, see step 2 and "Rules at the `payment` hook"). Look the hook up in `hooks[]`. Build the **fact document** (a JSON object with the top-level keys `booking`, `partner`, `product`, `venue`, `space`) by following `hooks[].facts[].howToObtain` for every fact. Evaluate `when` against it using the language reference below. Evaluate the matching rules in the order they appear in `rules[]` (priority ascending, then rule id); a `set` written by an earlier rule is visible to the later rules. 5. **Interpret the outcome.** If `when` is true, the `then` actions apply; `rules[].outcomes` already lists them so you need not parse `then`: `rejects` (the booking will be refused with `BKRUL-N-100` and `rejectMessages`), `writes` (values the platform will write, e.g. `booking.type = request` makes the booking an enquiry; at the `payment` hook the only write is `payment.finalBalance`, the amount payable) and `flags` (named annotations without any behaviour). If `when` is false the rule does nothing. If a fact is missing or has the wrong type for an operator, the rule is **skipped** (does not apply), it never fails the booking. 6. **Change the booking** (party size, date, space, pre-order lines, deposit) until no rule rejects it, or accept the enquiry outcome. 7. **When you are rejected**, read `violations[]` of the 400 response and match each `violations[].ruleId` with `rules[].id` from this API: it is the **same id**. Then fix what that rule says (`summary`, `message`, `params`). 8. **Re-fetch** whenever you start a new booking flow. Rules can be changed, added or deactivated by the operator at any time; this API offers no change notification and no cache validators, so do not assume that a previous answer is still current. The result is a pre-check: always handle a `BKRUL-*` rejection from the booking endpoint as the final word. ## Glossary | Term | Meaning | |---|---| | Operator / venue group | The company that owns the venues. `operatorId` is its numeric id (the first segment of every composite id). | | Rule | One booking condition configured by an operator. Identified by a UUID (`id`). | | Effect | The kind of rule. Seven fixed effects with fixed semantics plus `expression` (a rule defined by data). | | Hook | A named point in the flow where expression rules are evaluated: `booking.create` (before the booking is sent to the operator's system), `booking.amend` (before an amend) and `payment` (when the platform computes the amount payable for an existing booking). | | Final balance | The total amount payable for an existing booking, in integer pence (GBP), as the platform computes it when it builds the payment options. A rule at the `payment` hook can set it (`payment.finalBalance`). The 25% deposit option is computed on it. | | Minimum-spend band | A time window of the booked space with its own minimum spend (configured by the operator). `payment.bands` lists the bands that the booking interval [start, start + duration) overlaps. | | Fact | A named value an expression can read with `{"var": ""}` (for example `booking.partySize`). Facts are derived from your request; `hooks[].facts[].howToObtain` says how. | | Expression (`when`) | A boolean condition written as JSON (JsonLogic format). | | Action (`then`) | What happens when `when` is true: `set` a writable value, `reject` the booking, or `flag` it. | | Enquiry | A booking submitted as `type: request` instead of an instant confirmation: the operator must accept it. | | Pre-order line | One package or menu in `preorders` of a booking, with a quantity. Rules identify packages, menus and products by their **aggregation slug** (the `slug` field in `GET /venues`), not by the venue-specific id. | | Composite id | `{operatorId}\|{rmsSlug}\|{venueResourceId}` identifies a venue (3 segments); `{operatorId}\|{rmsSlug}\|{venueResourceId}\|{productId}` identifies a bookable product (4 segments). | | Party size | The `partySize` of the request (children are not added to it). | | `daysAhead` | Whole calendar days from today (Europe/London) to the booking date. | | Violation | One rule that rejected a booking, reported in `violations[]` of the 400 error. | ## Field guide: what to do with each field | Field | What it is | What an integration should DO with it | |---|---|---| | `generatedAt` | UTC instant at which the server built the answer. | Informational. There is no cache validator and rules can change at any moment: re-fetch at the start of every booking flow instead of comparing timestamps. | | `caller.partnerId` | Your own partner id. | Copy it into the fact `partner.id` when you evaluate expression rules. It is NOT a literal you read inside a rule's `when`: those are whatever the operator wrote. | | `caller.categoryIdsByOperator` | Object keyed by operator id (as a string): the ids of the partner categories you belong to **for that operator** (your own categories plus those the operator gives you through its own third-party records). One entry for every operator that has at least one rule in `rules[]`; empty object when `rules` is empty. | When you evaluate an expression rule, copy `caller.categoryIdsByOperator[String(rule.operatorId)]` into the fact `partner.categoryIds`. This is the same value the server uses to filter the rules. | | `caller.partnerCategoryIds` | The partner categories assigned directly to you, the same for every operator (global). Kept for backward compatibility. | Do not use it as `partner.categoryIds` when `caller.categoryIdsByOperator` has an entry for the rule's operator: it can miss categories the operator gives you. Use it only as a fallback when the entry is missing. | | `rules[].id` | Rule UUID. | Keep it: the same value comes back as `violations[].ruleId` and `analyticsRuleId` in a 400 rejection. | | `rules[].operatorId` | Operator (venue group) that owns the rule. | Keep the rules whose `operatorId` equals the first segment of the composite id you book. | | `rules[].effect` | Kind of rule: one of 9 values. | Every effect except `expression` is a fixed effect: apply the effects table. `expression`: evaluate `when` and apply `then`. | | `rules[].appliesTo` | Venue, product and partner scope, plus the item set `items`. | Filter as in step 2 of the integration guide. `items` is the set the effect works on, not a scope test. | | `rules[].conditions` | Extra request-field filters (usually empty). | All entries must hold, otherwise the rule does not apply to this booking. | | `rules[].params` | Parameters of a fixed effect, defaults already applied. | Plug them into the formulas of "How to evaluate each fixed effect". Empty for expression rules. | | `rules[].message` | The operator's own refusal text (optional). | Show it to your user; when absent the default message of the effect is what the 400 will carry. | | `rules[].summary` | One generated English sentence. | Show or log it; never parse it. | | `rules[].whenApplied` | Moments at which the rule runs: `booking_create`, `booking_amend`, `booking_payment`, `booking_cancel`. | Keep the rule only if the list contains the operation you are about to call (`booking_cancel`: `DELETE /venues/bookings/{bookingId}`, and the cancel of a Listings order line). `booking_payment` is the moment the platform computes the amount payable for an existing booking: it is not a request you send, and the rule is not a gate for your create or amend (see "Rules at the `payment` hook"). | | `rules[].consequence` | What happens when the rule is violated or triggers. | `rejects_booking`: change the booking until the condition holds. `requires_deposit`: collect the deposit and send it as `depositAmount`. `turns_booking_into_enquiry`: nothing to fix; the booking becomes an enquiry (book further ahead to avoid it). `skips_preorder_email`: nothing to fix; below `params.threshold` guests the Collins pre-order email is not sent, the pre-order is still posted. `conditional`: read `outcomes` (for a `payment` rule: it writes the amount payable, it never refuses). | | `rules[].errorCode` | The `BKRUL-N-xxx` code the booking endpoint returns when the rule rejects (`AVAIL-N-007` / `AVAIL-N-008` for a `booking_window` with `action: book`). | Use it to recognise the rule type in a rejection. Absent when the rule can never reject (always absent for a `payment` rule). | | `rules[].hook` | Expression rules: the point of the flow where the rule runs (`booking.create`, `booking.amend`, `payment`). | Look the hook up in `hooks[]` (same `name`) to know which facts it reads. For `payment` you do not build the facts: the platform computes them from the stored booking. | | `rules[].priority` | Expression rules: evaluation order inside the hook, ascending, ties by id. | Nothing to sort: `rules[]` is already in evaluation order. A `set` from an earlier rule is visible to the later ones. | | `rules[].when`, `rules[].then` | Expression rule condition and actions, JSON. | Evaluate with the expression language reference; `then` only runs when `when` is true. | | `rules[].outcomes` | What `then` does, already parsed. | `rejects` with `rejectMessages`: the booking would be refused (`BKRUL-N-100`); always false at the `payment` hook, where a `reject` action has no effect. `writes`: values the platform writes (`booking.type = request` makes an enquiry; `payment.finalBalance` sets the amount payable). `flags`: named annotations without behaviour. | | `hooks[].facts[].howToObtain` | How to compute that fact from the request you send (create) or the booking you amend (amend). | Follow it literally. The server keeps one text per fact for create and one for amend and returns the one that belongs to the hook in `howToObtain`; there is no separate `howToObtainOnAmend` field. | | `hooks[].writablePaths` | The only paths a `set` action can write at that hook. | Informational: it tells you which consequences an expression rule can have (`booking.type`, `booking.enquiryReason`, `booking.sendPreorderEmail` at `booking.create`: `false` skips the Collins pre-order email, the pre-order itself is still posted; none at `booking.amend`; `payment.finalBalance` at `payment`: the total payable in integer pence, GBP, a number from 0 up, and the 25% deposit option is computed on it). | ## Effects | Effect | What it enforces | Params | Applies on | Error code | Default message when the rule has no `message` | |---|---|---|---|---|---| | `same_package` | Let S be the rule's item set (`appliesTo.items`, packages and menus compared by slug). Nothing happens unless at least one chosen pre-order line is in S. With `exclusive` true (default): violated if more than one distinct S item is chosen, or if any chosen line is outside S ("if this one is chosen, everybody has that same one and nothing else is added"). With `exclusive` false: violated only if more than one distinct S item is chosen; lines outside S are ignored. If not violated and `wholeGroup` is true, the summed quantity of the chosen S lines must equal the party size, otherwise it is violated (same code). | `exclusive`, `wholeGroup` | create, amend | `BKRUL-N-001` | exclusive: "If this package is chosen, everyone must have the same one and nothing else can be added." non-exclusive: "Everyone in the party must choose the same item from this set — selections cannot be mixed." wholeGroup: "Once chosen, this item's quantity must equal the party size (N)." | | `max_quantity_party_size` | Total quantity of the chosen lines in S may not exceed the party size. An empty S means every chosen package and menu counts. With `perItem` true, each item's own quantity (lines of the same slug added together) may not exceed the party size instead of the total. | `perItem` | create, amend | `BKRUL-N-002` | "The total quantity of these items (T) cannot exceed the party size (N)." (`perItem`: "No single item's quantity (T) can exceed the party size (N).", T = the largest item quantity) | | `max_quantity` | Total quantity of the chosen lines in S may not exceed `max`. (An empty S counts nothing and never violates.) With `perItem` true, each item's own quantity may not exceed `max` instead of the total. | `max`, `perItem` | create, amend | `BKRUL-N-003` | "No more than M of this item is allowed per booking." (`perItem`: "No more than M of each item is allowed per booking.") | | `preorder_required` | If the party size is `threshold` or more (inclusive: party size >= threshold), at least one chosen pre-order line must be in S. An empty S means any package or menu qualifies. | `threshold` | create, amend | `BKRUL-N-004` | "Groups of N or more must include a pre-order." | | `route_to_enquiry` | A booking made fewer than `leadTimeDays` calendar days ahead (`daysAhead < leadTimeDays`) is submitted as an **enquiry** instead of being confirmed. Exactly `leadTimeDays` ahead is NOT inside the window. It never rejects. | `leadTimeDays` | **create only** (an amend never un-confirms a booking) | none (consequence is `turns_booking_into_enquiry`) | not applicable | | `lock_preorder_edits` | Inside the window (`daysAhead < leadTimeDays`) pre-orders may not be added or changed. On create: violated when the booking carries any pre-order line. On amend: violated when the pre-order lines (slug, kind, quantity, order ignored) differ from the stored ones. Outside the window nothing is enforced. | `leadTimeDays` | create, amend | `BKRUL-N-005` | "Pre-order changes are not permitted less than N days before the booking date." | | `require_deposit` | The booking must already carry a deposit of at least `amount` x party size (`per: person`) or `amount` flat (`per: booking`). On create the figure checked is the request `depositAmount` (absent = 0); on amend it is the deposit stored on the booking (an amend request that carries a new `depositAmount` is not credited for this check). Tolerance of half a penny. It only checks the figure: it never collects money or creates a payment link. A rule without a usable `amount` never blocks. | `amount`, `per` | create, amend | `BKRUL-N-006` | "A deposit of £X is required for this booking (£A per person)." (`per: booking`: "(£A per booking)"; X and A with two decimals) | | `preorder_email` | If the party size is below `threshold` (party size < threshold), the Collins pre-order email is not sent; the pre-order itself is still posted. It never rejects. On amend: when the email was skipped on create and the amended booking has a pre-order and reaches `threshold`, the email is sent once. | `threshold` | create, amend | none (consequence is `skips_preorder_email`) | not applicable | | `booking_window` | You may not `book`, `amend` or `cancel` (`action`) when the reservation starts in fewer than `minHoursBefore` hours, and (book only) may not book more than `maxHoursBefore` hours ahead. Hours are real elapsed time from your request to the reservation start (booking date + start time, Europe/London); exactly `minHoursBefore` away is allowed. An amend is measured from the start **stored** on the booking, so moving it further out does not reopen the window; an amend that changes the date or time must also pass the book window, an amend that doesn't is never checked against it. Amend and cancel windows apply to confirmed bookings only (a pending request can always be changed or withdrawn). When several rules match, the strictest window wins. | `action`, `minHoursBefore`, `maxHoursBefore` | `book`: create, and amend when the date or time changes. `amend`: amend. `cancel`: cancel | `book`: `AVAIL-N-007` (too close), `AVAIL-N-008` (too far ahead); `amend`: `BKRUL-N-007`; `cancel`: `BKRUL-N-008` | "Cancellations close 5 days (120 hours) before the reservation; they closed at 2026-10-20 20:00 Europe/London." (`Bookings` / `Amendments` for the other actions; past the maximum: "Bookings open N hours before the reservation; they open at ….") | | `expression` | A generic rule defined by data: at `hook`, when `when` is true, apply `then`. | none (use `hook`, `priority`, `when`, `then`) | the hook's moment: `booking.create` = create, `booking.amend` = amend, `payment` = when the amount payable of an existing booking is computed (`booking_payment`) | `BKRUL-N-100` when it rejects (never at `payment`, where a `reject` has no effect) | the message of the `reject` action, else the rule's `message`, else "Rule '' rejected the booking." | Numbers for each effect, with one violating and one passing booking, are in "How to evaluate each fixed effect" below. `params` defaults applied by the platform when a key is missing: `exclusive` true, `wholeGroup` false, `threshold` 1, `leadTimeDays` 0, `per` person. The values returned by this API are already resolved. ### Which rules apply to a booking (scope) A rule applies to a booking when ALL of these hold: it belongs to the booked operator; its venue list is empty (every venue) or contains the booked venue; its product list is empty (every product) or contains the booked product's aggregation slug; it has no partner target, or targets your partner id, or targets one of your categories (either match is enough); and every `conditions[]` entry holds. Item lists (`appliesTo.items`) are not part of the scope test of any effect: they are the *set the effect works on*. For `expression` rules the item lists are informational only and are not evaluated. ### Field conditions `conditions[]` is an optional extra filter: each entry compares one request field with a value and ALL entries must hold. Fields: on create `partySize`, `duration`, `spaceId`, `isCrossSale`, `commission`, `depositAmount`, `hasCustomerCompany`, `hasNotes`; on amend only `partySize`, `duration`, `commission`, `hasCustomerCompany`, `hasNotes`; at `payment` (read from the stored booking) `partySize`, `duration`, `spaceId`, `commission`, `depositAmount`, `hasCustomerCompany`, `hasNotes`. A field the booking does not carry never matches, whatever the operator (even `not_equals`). Operators: `equals`, `not_equals`, `gte`, `lte` (numbers), `in` (the field value is one of the listed values), `contains` (the field value is a list containing the value). At the time of writing the operator tooling has no way to create conditions, so you will normally see an empty list. ### Ordering and combination - Fixed rules are all evaluated; **every** fixed violation is reported in `violations[]`, and the error shown (`code`, `title`, `detail`) is the one with the highest precedence: `lock_preorder_edits`, then `preorder_required`, then `same_package`, then `max_quantity_party_size`, then `max_quantity`, then `require_deposit`. - **Expression rules are evaluated only after all fixed rules passed.** A booking rejected by a fixed rule never shows expression violations. On create, a fixed `route_to_enquiry` has already set `booking.type = request` and `booking.enquiryReason = operatorRule` when the expression rules run, so expressions see those values. - Expression rules of one hook run by `priority` ascending (default 100), then by rule id. `rules[]` is returned in that order: expression rules of `booking.create`, then of `booking.amend`, then of `payment`. - Rules of the `payment` hook run at their own moment (see the next section), never together with the fixed rules or with the rules of the booking hooks. - Several `set` actions on one path: they compose in that order and **the last one wins**. Every later rule (its `when` and its actions) reads the facts *plus the writes made so far*. - A rule is **atomic**: if one of its actions cannot be carried out (for example a `set` value is not computable), the whole rule is skipped and none of its rejections, flags or writes is kept. - Everything an expression rule cannot evaluate is silently **skipped**, never an error for you: a fact that is null or missing where an operator needs a number, string, boolean or list, a type mismatch, division by zero, a `when` that is not boolean, a `set` to a path that is not writable at the hook, or a limit overrun. ### Rules at the `payment` hook (moment `booking_payment`) A rule with `hook: payment` and `whenApplied: [booking_payment]` does not accept or refuse a booking. It changes **how much is payable** for a booking that already exists. Everything below is read from the platform's payment code. - **When it runs.** Each time the platform computes the payable amount of an existing booking: when you call `GET /venues/bookings/{bookingId}/payment-options` (operationId `getPaymentOptions`, Payment API) and when you call `POST /venues/bookings/{bookingId}/payments` (operationId `createPayment`). It is **not** evaluated at booking create or amend, and it does not change what availability or `GET /venues` show: availability lists each minimum-spend band the slot's interval [start, start + duration) crosses, each with its own value and no total, and `GET /venues` shows the configured rules and overrides. - **Preconditions.** The booked space has an enabled minimum-spend policy and the stored booking has a duration greater than 0. Without them no `payment` rule is consulted and the amount is the minimum spend at the start time, as before. A rule is also subject to the scope tests above (operator, venue, product, partner, `conditions[]`), evaluated on the stored booking. - **Facts.** All facts are computed by the platform from the **stored** booking and the space's minimum-spend policy; you do not send them. The new root is `payment`: `payment.bands` (the minimum-spend bands that the interval [start, start + duration) overlaps, in chronological order, each band once, each with `label`, `from`, `to`, `spendType`, `spendValue` in pence, `hireFeeValue` in pence and `overlapMinutes`), `payment.bandCount`, `payment.minimumSpend` (the value the options use when no rule applies: the first matching entry at the start time only, null when no band contains the start time) and `payment.currency` (always `GBP`). The `booking.*`, `partner.*`, `product.*`, `venue.*` and `space.*` facts are those of the stored booking; `booking.items` and `booking.originalItems` are always empty, `booking.isCreate` is false, and `booking.type`, `booking.enquiryReason` and `booking.isCrossSale` are null at this hook. `hooks[]` documents each of them for this hook. - **The only write.** `payment.finalBalance`: the total payable, an integer number of pence (GBP). The value must be a number from 0 up to 2147483647; anything else (negative, not a number, too large) makes the rule be skipped. A value with decimals is rounded to the nearest penny (halves away from zero). If several rules write it, the last one wins (priority, then rule id). A value equal to the minimum spend at the start time changes nothing. - **What follows from the value.** The "full minimum spend" option is the final balance. The "25% deposit" option is computed on it: the final balance times 0.25, rounded to a whole penny (a half rounds to the even penny). When the resulting amount is not greater than 0 the options list is empty, and `createPayment` cannot create the payment. - **What has no effect.** A `reject` action at this hook is ignored by the platform (it only logs a warning), so `outcomes.rejects` is always false for a `payment` rule and `errorCode` is omitted. A `flag` has no behaviour here either. A rule that cannot be evaluated, or an error while evaluating, keeps the amount the platform would have used without the rule; it never fails the payment call. - **What you should do with it.** You cannot rebuild `payment.bands`: it comes from the space's minimum-spend policy (rules and date overrides configured by the operator), which is not part of your booking request and is not returned by this API. Do **not** evaluate a `payment` rule client-side as a gate. Read it to know that the final balance may be the **sum of the minimum-spend bands the booking crosses**, and take the authoritative amount from the payment options (`getPaymentOptions`): amounts are integers in pence. The amount can change if the booking is amended or the operator changes the rule, so ask again before you pay. ## How to evaluate each fixed effect Every example uses operator 17, the venue `17|X9|9d3f0b6e7a1c4e58b2c4f1a60d7e8b93` (The Rooftop Kitchen), the product `bottomless-brunch` unless stated, partner 482 (category 1) and "today" = 2026-10-01. The rules are the ones of the `allRules` example of `GET /booking-rules` (cited by the first 8 characters of their id). For each effect: the inputs, the computation, then one violating and one passing booking. The JSON block after each effect is the same scenarios in machine-readable form: `rule` (id prefix), `partySize`, `date`, `create` (false = amend), `packages` / `menus` (`[slug, quantity]` pairs of the final booking), `original` (stored lines on an amend), `deposit` (the request `depositAmount` on create, the stored deposit on amend), `product` (slug of the booked product) and `expect`. The platform's test-suite runs every scenario against the real evaluator, so these numbers cannot drift from the behaviour. Quantities are those of the pre-order lines (a package or menu line counts its own `quantity`, not its items). `daysAhead` = booking date minus 2026-10-01 in whole days. ### same_package with `exclusive: true` (rule 9c1d5e72) Rule: set S = {`premium-bottomless-brunch`}, `exclusive` true, `wholeGroup` false. Party of 6, date 2026-11-14. Computation: (1) take the chosen lines whose slug is in S; none means the rule is inert. (2) More than one distinct S slug: violated. (3) `exclusive` and any chosen line outside S: violated. (4) `wholeGroup` true only: the summed quantity of the S lines must equal the party size. - Violating: premium x2 + standard x4. S lines = {premium}; the standard line is outside S and `exclusive` is true: `BKRUL-N-001`, "If this package is chosen, everyone must have the same one and nothing else can be added." - Violating: premium x2 + mocktail-pairing x4. The mocktail is outside S: `BKRUL-N-001`. - Passing: premium x6, or premium x2 (`wholeGroup` is false, so the quantity need not equal the party size 6). - Passing: standard x6. No S line is chosen, the rule is inert. #### Scenarios - same_package exclusive ```json [ {"rule": "9c1d5e72", "partySize": 6, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 2], ["standard-bottomless-brunch", 4]], "expect": {"violation": "BKRUL-N-001", "message": "If this package is chosen, everyone must have the same one and nothing else can be added."}}, {"rule": "9c1d5e72", "partySize": 6, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 2], ["mocktail-pairing", 4]], "expect": {"violation": "BKRUL-N-001", "message": "If this package is chosen, everyone must have the same one and nothing else can be added."}}, {"rule": "9c1d5e72", "partySize": 6, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 6]], "expect": {"violation": null}}, {"rule": "9c1d5e72", "partySize": 6, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 2]], "expect": {"violation": null}}, {"rule": "9c1d5e72", "partySize": 6, "date": "2026-11-14", "packages": [["standard-bottomless-brunch", 6]], "expect": {"violation": null}} ] ``` ### same_package with `exclusive: false` and `wholeGroup: true` (rule 2b7f9d40) Rule: S = {`premium-bottomless-brunch`, `standard-bottomless-brunch`}. Party of 6, date 2026-11-14. Computation: (1) no S line chosen: inert. (2) More than one distinct S slug: violated (lines outside S are ignored because `exclusive` is false). (3) `wholeGroup`: the summed quantity of the S lines must equal the party size. - Violating (mixing): premium x3 + standard x3. Two distinct S items: `BKRUL-N-001`, "Everyone in the party must choose the same item from this set — selections cannot be mixed." - Violating (whole group): premium x4 only. One S item but 4 is not 6: `BKRUL-N-001`, "Once chosen, this item's quantity must equal the party size (6)." - Passing: premium x6 + mocktail-pairing x2. The S lines sum to 6 and the mocktail is outside S, so it is ignored. - Passing: mocktail-pairing x6 only. No S line, inert. #### Scenarios - same_package non-exclusive whole group ```json [ {"rule": "2b7f9d40", "partySize": 6, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 3], ["standard-bottomless-brunch", 3]], "expect": {"violation": "BKRUL-N-001", "message": "Everyone in the party must choose the same item from this set — selections cannot be mixed."}}, {"rule": "2b7f9d40", "partySize": 6, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 4]], "expect": {"violation": "BKRUL-N-001", "message": "Once chosen, this item's quantity must equal the party size (6)."}}, {"rule": "2b7f9d40", "partySize": 6, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 6], ["mocktail-pairing", 2]], "expect": {"violation": null}}, {"rule": "2b7f9d40", "partySize": 6, "date": "2026-11-14", "packages": [["mocktail-pairing", 6]], "expect": {"violation": null}} ] ``` ### max_quantity_party_size (rule 6d40c8e1) Rule: S is empty, so every chosen package and menu counts. Party of 4, date 2026-11-14. Computation: total = sum of the quantities of the counted lines; violated when total > party size (equal is allowed). - Violating: premium x3 + standard x2. Total 5 > 4: `BKRUL-N-002`, "The total quantity of these items (5) cannot exceed the party size (4)." - Violating: premium x2 + kids-menu (a menu) x3. Menus count too: 5 > 4. - Passing: premium x2 + standard x2. Total 4, and 4 > 4 is false. - Passing: no pre-order at all. Total 0. #### Scenarios - max_quantity_party_size ```json [ {"rule": "6d40c8e1", "partySize": 4, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 3], ["standard-bottomless-brunch", 2]], "expect": {"violation": "BKRUL-N-002", "message": "The total quantity of these items (5) cannot exceed the party size (4)."}}, {"rule": "6d40c8e1", "partySize": 4, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 2]], "menus": [["kids-menu", 3]], "expect": {"violation": "BKRUL-N-002", "message": "The total quantity of these items (5) cannot exceed the party size (4)."}}, {"rule": "6d40c8e1", "partySize": 4, "date": "2026-11-14", "packages": [["premium-bottomless-brunch", 2], ["standard-bottomless-brunch", 2]], "expect": {"violation": null}}, {"rule": "6d40c8e1", "partySize": 4, "date": "2026-11-14", "expect": {"violation": null}} ] ``` ### max_quantity (rule a13e5f87) Rule: S = {menu `kids-menu`}, `max` 4. Party of 10, date 2026-11-14. Computation: total = sum of the quantities of the chosen lines in S (lines outside S are not counted); violated when total > `max` (exactly `max` is allowed). - Violating: kids-menu x5. 5 > 4: `BKRUL-N-003`, "No more than 4 of this item is allowed per booking." - Passing: kids-menu x4. 4 > 4 is false. - Passing: kids-menu x3 + sunday-roast-menu x6. Only the S line counts (3). #### Scenarios - max_quantity ```json [ {"rule": "a13e5f87", "partySize": 10, "date": "2026-11-14", "menus": [["kids-menu", 5]], "expect": {"violation": "BKRUL-N-003", "message": "No more than 4 of this item is allowed per booking."}}, {"rule": "a13e5f87", "partySize": 10, "date": "2026-11-14", "menus": [["kids-menu", 4]], "expect": {"violation": null}}, {"rule": "a13e5f87", "partySize": 10, "date": "2026-11-14", "menus": [["kids-menu", 3], ["sunday-roast-menu", 6]], "expect": {"violation": null}} ] ``` ### preorder_required (rules c58b0a92 with items, f2a6d3c1 without) Rule c58b0a92: S = {`premium-bottomless-brunch`, `standard-bottomless-brunch`}, `threshold` 8. Date 2026-11-14. Computation: if party size < threshold the rule is inert (party size >= threshold is "inside"); otherwise at least one chosen line must be in S. - Violating: party 8, no pre-order. 8 >= 8 and no S line: `BKRUL-N-004`, "Groups of 8 or more must include a pre-order." - Violating: party 8 with mocktail-pairing x8 only. The mocktail is outside S. - Passing: party 7, no pre-order. 7 < 8, inert. - Passing: party 8 with standard x8. Rule f2a6d3c1: S is empty, so ANY chosen package or menu qualifies, `threshold` 12. - Violating: party 12, no pre-order: `BKRUL-N-004`, "Groups of 12 or more must include a pre-order." - Passing: party 12 with mocktail-pairing x1 (any line qualifies). - Passing: party 11, no pre-order (11 < 12). #### Scenarios - preorder_required ```json [ {"rule": "c58b0a92", "partySize": 8, "date": "2026-11-14", "expect": {"violation": "BKRUL-N-004", "message": "Groups of 8 or more must include a pre-order."}}, {"rule": "c58b0a92", "partySize": 8, "date": "2026-11-14", "packages": [["mocktail-pairing", 8]], "expect": {"violation": "BKRUL-N-004", "message": "Groups of 8 or more must include a pre-order."}}, {"rule": "c58b0a92", "partySize": 7, "date": "2026-11-14", "expect": {"violation": null}}, {"rule": "c58b0a92", "partySize": 8, "date": "2026-11-14", "packages": [["standard-bottomless-brunch", 8]], "expect": {"violation": null}}, {"rule": "f2a6d3c1", "partySize": 12, "date": "2026-11-14", "expect": {"violation": "BKRUL-N-004", "message": "Groups of 12 or more must include a pre-order."}}, {"rule": "f2a6d3c1", "partySize": 12, "date": "2026-11-14", "packages": [["mocktail-pairing", 1]], "expect": {"violation": null}}, {"rule": "f2a6d3c1", "partySize": 11, "date": "2026-11-14", "expect": {"violation": null}} ] ``` ### route_to_enquiry (rule 0e9c7b35, create only) Rule: `leadTimeDays` 7. Computation: `daysAhead` = booking date minus today; the booking becomes an enquiry when `daysAhead < 7`. It never rejects and is never evaluated on an amend. - Enquiry: date 2026-10-04. `daysAhead` 3 < 7. - Enquiry: date 2026-10-07. `daysAhead` 6 < 7. - Confirmed normally: date 2026-10-08. `daysAhead` 7 and 7 < 7 is false: exactly `leadTimeDays` ahead is outside the window. - Not affected: an amend of a booking dated 2026-10-04 (the effect is create only). #### Scenarios - route_to_enquiry ```json [ {"rule": "0e9c7b35", "partySize": 4, "date": "2026-10-04", "expect": {"enquiry": true}}, {"rule": "0e9c7b35", "partySize": 4, "date": "2026-10-07", "expect": {"enquiry": true}}, {"rule": "0e9c7b35", "partySize": 4, "date": "2026-10-08", "expect": {"enquiry": false}}, {"rule": "0e9c7b35", "partySize": 4, "date": "2026-10-04", "create": false, "expect": {"enquiry": false}} ] ``` ### lock_preorder_edits (rule 7a3d1f96) Rule: `leadTimeDays` 7. Computation: inside the window (`daysAhead < 7`): on create the booking must carry no pre-order line; on amend the final lines (slug, kind, quantity; order ignored) must equal the stored lines. Outside the window nothing is enforced. - Violating (create): date 2026-10-04 (`daysAhead` 3) with premium x6: `BKRUL-N-005`, "Pre-order changes are not permitted less than 7 days before the booking date." - Passing (create): date 2026-10-08 (`daysAhead` 7, outside the window) with premium x6. - Passing (create): date 2026-10-04 with no pre-order line. - Violating (amend): date 2026-10-04, stored premium x6, final premium x4: the lines differ. - Passing (amend): date 2026-10-04, stored [premium x4, standard x2], final [standard x2, premium x4]: same lines in another order. - Passing (amend): date 2026-10-08, stored premium x6, final premium x4: outside the window. #### Scenarios - lock_preorder_edits ```json [ {"rule": "7a3d1f96", "partySize": 6, "date": "2026-10-04", "packages": [["premium-bottomless-brunch", 6]], "expect": {"violation": "BKRUL-N-005", "message": "Pre-order changes are not permitted less than 7 days before the booking date."}}, {"rule": "7a3d1f96", "partySize": 6, "date": "2026-10-08", "packages": [["premium-bottomless-brunch", 6]], "expect": {"violation": null}}, {"rule": "7a3d1f96", "partySize": 6, "date": "2026-10-04", "expect": {"violation": null}}, {"rule": "7a3d1f96", "partySize": 6, "date": "2026-10-04", "create": false, "original": {"packages": [["premium-bottomless-brunch", 6]]}, "packages": [["premium-bottomless-brunch", 4]], "expect": {"violation": "BKRUL-N-005", "message": "Pre-order changes are not permitted less than 7 days before the booking date."}}, {"rule": "7a3d1f96", "partySize": 6, "date": "2026-10-04", "create": false, "original": {"packages": [["premium-bottomless-brunch", 4], ["standard-bottomless-brunch", 2]]}, "packages": [["standard-bottomless-brunch", 2], ["premium-bottomless-brunch", 4]], "expect": {"violation": null}}, {"rule": "7a3d1f96", "partySize": 6, "date": "2026-10-08", "create": false, "original": {"packages": [["premium-bottomless-brunch", 6]]}, "packages": [["premium-bottomless-brunch", 4]], "expect": {"violation": null}} ] ``` ### require_deposit (rules e07a4b19 per person, d4b82e60 per booking) Computation: required = `amount` x party size (`per: person`) or `amount` (`per: booking`). Collected = the request `depositAmount` on create (absent = 0), the deposit stored on the booking on amend. Violated when collected + 0.005 < required (half a penny of tolerance). The platform never collects money: you must collect it and send it as `depositAmount`. Rule e07a4b19: `amount` 10.00, `per` person, its own `message`. Party of 6, date 2026-11-14. - Violating: no `depositAmount`. Required 10.00 x 6 = 60.00, collected 0: `BKRUL-N-006` with the rule's own message. - Passing: `depositAmount` 60.00. - Violating: `depositAmount` 59.99. 59.99 + 0.005 = 59.995 < 60.00. - Violating: party 8 with `depositAmount` 60.00. Required 80.00. Rule d4b82e60: `amount` 100.00, `per` booking, limited to the product `sunday-lunch`, no `message`. Party of 6. - Violating: product `sunday-lunch`, `depositAmount` 99.99: `BKRUL-N-006`, "A deposit of £100.00 is required for this booking (£100.00 per booking)." - Passing: `depositAmount` 100.00 (party size is irrelevant for `per: booking`). - Passing: product `bottomless-brunch` with no deposit (the rule is limited to `sunday-lunch`, so it does not apply). - Passing (amend): the stored deposit is 100.00. #### Scenarios - require_deposit ```json [ {"rule": "e07a4b19", "partySize": 6, "date": "2026-11-14", "expect": {"violation": "BKRUL-N-006", "message": "Please collect the 10 pound per person deposit and send it as depositAmount."}}, {"rule": "e07a4b19", "partySize": 6, "date": "2026-11-14", "deposit": 60.00, "expect": {"violation": null}}, {"rule": "e07a4b19", "partySize": 6, "date": "2026-11-14", "deposit": 59.99, "expect": {"violation": "BKRUL-N-006", "message": "Please collect the 10 pound per person deposit and send it as depositAmount."}}, {"rule": "e07a4b19", "partySize": 8, "date": "2026-11-14", "deposit": 60.00, "expect": {"violation": "BKRUL-N-006", "message": "Please collect the 10 pound per person deposit and send it as depositAmount."}}, {"rule": "d4b82e60", "product": "sunday-lunch", "partySize": 6, "date": "2026-11-14", "deposit": 99.99, "expect": {"violation": "BKRUL-N-006", "message": "A deposit of £100.00 is required for this booking (£100.00 per booking)."}}, {"rule": "d4b82e60", "product": "sunday-lunch", "partySize": 6, "date": "2026-11-14", "deposit": 100.00, "expect": {"violation": null}}, {"rule": "d4b82e60", "product": "bottomless-brunch", "partySize": 6, "date": "2026-11-14", "expect": {"violation": null}}, {"rule": "d4b82e60", "product": "sunday-lunch", "partySize": 6, "date": "2026-11-14", "create": false, "deposit": 100.00, "expect": {"violation": null}} ] ``` ### booking_window Computation: hours = (reservation start - now), real elapsed time, with the reservation start = booking date + start time in Europe/London. Too close when hours < `minHoursBefore`; too far ahead (book only) when hours > `maxHoursBefore`. On amend the `amend` window is measured from the start stored on the booking, and the `book` window from the new start (only when the date or time changes). The operator's own advance-booking limits on the booking type still apply on top: a rule can only narrow them. A rule with `action` cancel and `minHoursBefore` 120, reservation 2026-10-25 19:00 (the clocks go back at 02:00 that morning): - Violating: cancel at 2026-10-20 21:00 Europe/London (119 real hours before): `BKRUL-N-008`, "Cancellations close 5 days (120 hours) before the reservation; they closed at 2026-10-20 20:00 Europe/London." - Passing: cancel at 2026-10-20 20:00 Europe/London, exactly 120 real hours before (counting wall-clock hours would wrongly give 119, because of the clock change). - Passing: the booking is still a pending request. A rule with `action` amend and `minHoursBefore` 240, stored reservation 2026-11-05 19:00, now 2026-10-30 12:00: - Violating: moving it to 2026-12-20 (the stored start is 151 hours away): `BKRUL-N-007`. A rule with `action` book, `minHoursBefore` 48 and `maxHoursBefore` 2400: - Violating: a booking for 30 hours ahead: `AVAIL-N-007`. A booking for 2,500 hours ahead: `AVAIL-N-008`. - Passing (amend): a notes change on a confirmed booking now 30 hours away (date and time unchanged). ### Expression rules: the same method, at `booking.amend` (rule 1c6e9b48) An expression rule is evaluated by building the fact document of its hook, evaluating `when`, then applying `then` (see the language reference). Rule 1c6e9b48 runs when you PUT or PATCH a booking: it rejects an amend that leaves no premium brunch line when the stored booking had one. At `booking.amend`, `booking.originalItems` holds the lines stored before the amend and `booking.items` the lines the booking will have after it. - Violating: stored [premium x6, standard x4], final [standard x10]. `some(booking.originalItems, slug == premium)` is true and `none(booking.items, slug == premium)` is true, so `when` is true: `BKRUL-N-100`, "The Premium Bottomless Brunch cannot be removed from an existing booking." - Passing: stored [premium x6, standard x4], final [premium x10]. `none(booking.items, ...)` is false. - Passing: stored [standard x10], final [standard x6]. The stored booking had no premium line, `some` is false. #### Checks - expression at booking.amend ```json [ {"rules": ["1c6e9b48"], "input": {"create": false, "partySize": 10, "original": {"packages": [["premium-bottomless-brunch", 6], ["standard-bottomless-brunch", 4]]}, "packages": [["standard-bottomless-brunch", 10]]}, "expect": {"rejects": true, "applied": ["1c6e9b48"]}}, {"rules": ["1c6e9b48"], "input": {"create": false, "partySize": 10, "original": {"packages": [["premium-bottomless-brunch", 6], ["standard-bottomless-brunch", 4]]}, "packages": [["premium-bottomless-brunch", 10]]}, "expect": {"rejects": false, "applied": []}}, {"rules": ["1c6e9b48"], "input": {"create": false, "partySize": 6, "original": {"packages": [["standard-bottomless-brunch", 10]]}, "packages": [["standard-bottomless-brunch", 6]]}, "expect": {"rejects": false, "applied": []}} ] ``` ## Expression language reference `when` is one expression tree; the rule applies when it evaluates to the boolean `true`. **Node forms.** A literal (number, string, `true`, `false`, `null`); a list of nodes (`["a", "b"]`); `{"var": ""}` reads a fact by dotted path (object members by name, list elements by zero-based index); `{"var": ["", ]}` supplies a default for a missing or null fact; `{"var": ""}` is the current element inside a list predicate; or `{"": []}` (a single non-array argument is accepted: `{"!": x}`). A list-valued single argument of `count`, `sum` or `distinct` is passed unwrapped: `{"count": {"var": "booking.items"}}`. An object literal cannot be written. Numbers are exact decimals. | Group | Operators | Semantics | |---|---|---| | Comparison | `==` `!=` | Both operands must be the same kind (number, string, boolean, list, object, null); null equals only null. A different kind is a type clash and the whole rule is skipped (there is no coercion): `null == "Terrace"` skips the rule, it does not give false. | | Comparison | `>` `>=` `<` `<=` | All operands numbers, or all strings (ordinal comparison, so ISO dates compare correctly). Three operands mean "between": `{"<=": [1, {"var": "booking.partySize"}, 8]}`. | | Logic | `and` `or` `!` `if` | Operands must be booleans (no truthiness). `and` / `or` short-circuit left to right, so an operand after the deciding one is never evaluated (and cannot cause a skip). `{"if": [cond1, v1, cond2, v2, else]}` evaluates only the branch taken. | | Arithmetic | `+` `-` `*` `/` `%` `min` `max` | Numbers only. `-` with one operand negates. Division or modulo by zero skips the rule. | | Membership | `in` | `{"in": [needle, haystack]}`. Haystack list: true if an element equals the needle (a different kind is simply not equal). Haystack string: ordinal substring test, the needle must be a string. Any other haystack (including null) skips the rule. | | List predicates | `filter` `map` `some` `all` `none` | `{"some": [, ]}`. Inside the predicate `{"var": "slug"}` is a field of the **current element** and `{"var": ""}` is the element itself; the rest of the facts are not reachable there. The predicate of filter, some, all and none must be boolean. `some` of an empty list is false, `all` and `none` of an empty list are true. | | Aggregates | `count` `sum` `distinct` `pluck` | `count` and `sum` of an empty list are 0; `sum` needs numbers; `distinct` removes duplicates; `pluck` takes the named field of every object element: `{"pluck": [, "quantity"]}` (the field name is a string literal; absent field gives null). | **Limits** (an expression that exceeds them is skipped at booking time): depth 12, 96 nodes, at most 8 actions in `then`, lists of at most 200 elements, 2000 elementary operations per evaluation. **Actions** (`then`, 1 to 8, each an object with exactly one key): `{"set": {"path": "booking.type", "value": "request"}}` writes the value (a literal, or an expression evaluated like `when`) to a path that is writable at the hook (`hooks[].writablePaths`); `{"reject": {"message": "..."}}` refuses the booking (the message is optional; ignored at the `payment` hook); `{"flag": {"name": "vip"}}` attaches a named annotation with no behaviour of its own. **What `booking.type = request` plus `booking.enquiryReason = operatorRule` means for you.** The booking is not confirmed instantly: it is created as an enquiry (type `request`) that the operator has to accept, whatever `type` you sent. `enquiryReason = operatorRule` marks that an operator rule, not availability or your own enquiry policy, made that decision (the Collins integration then skips its per-partner enquiry-policy checks). No error is returned. How other table management systems treat an enquiry: not verified beyond Collins. The `route_to_enquiry` effect does the same without an expression. ## Expression cheat-sheet The ten patterns operators use most, each as a complete rule fragment (`when` + `then`) and its plain-English reading. Facts are the dotted paths of `hooks[].facts[]`. The checks at the end are executed by the platform's test-suite against the real expression engine, so every pattern here is proven. Use them as templates to READ rules; you never send a rule. ### Pattern 1 - Compare a fact with a number ```json {"when": {">": [{"var": "booking.partySize"}, 8]}, "then": [{"reject": {"message": "Parties of more than 8 cannot book online."}}]} ``` Reading: reject when the party size is greater than 8 (a party of exactly 8 passes). ### Pattern 2 - Is a fact one of a set of values ```json {"when": {"in": [{"var": "product.slug"}, ["bottomless-brunch", "sunday-lunch"]]}, "then": [{"reject": {"message": "This product cannot be booked through this channel."}}]} ``` Reading: reject when the booked product is `bottomless-brunch` or `sunday-lunch`. A null `product.slug` is simply "not in the list" (false), it does not skip the rule. ### Pattern 3 - Count the pre-order lines ```json {"when": {"==": [{"count": {"var": "booking.items"}}, 0]}, "then": [{"reject": {"message": "A pre-order is required."}}]} ``` Reading: reject when there is no pre-order line. `count` of `booking.items` equals the fact `booking.itemCount`; it counts lines, not quantities. ### Pattern 4 - Sum the quantities of selected items ```json {"when": {">": [ {"sum": {"pluck": [{"filter": [{"var": "booking.items"}, {"==": [{"var": "kind"}, "package"]}]}, "quantity"]}}, {"var": "booking.partySize"}]}, "then": [{"reject": {"message": "More packages than guests."}}]} ``` Reading: keep the package lines (`kind` is the field of the current line), take their `quantity`, add them up and reject when the total is greater than the party size. `booking.partySize` is outside the `filter`, where it is reachable. ### Pattern 5 - Check the partner category ```json {"when": {"in": [1, {"var": "partner.categoryIds"}]}, "then": [{"reject": {"message": "Not available to this partner category."}}]} ``` Reading: reject when partner category 1 is among the categories of the calling partner for the rule's operator (`caller.categoryIdsByOperator[rule.operatorId]`). ### Pattern 6 - Days-ahead window ```json {"when": {"and": [{">=": [{"var": "booking.daysAhead"}, 0]}, {"<": [{"var": "booking.daysAhead"}, 7]}]}, "then": [{"reject": {"message": "Bookings less than 7 days ahead are not accepted."}}]} ``` Reading: reject when the booking date is today or within the next 6 days (0 <= daysAhead < 7); a date 7 days ahead passes. ### Pattern 7 - Null-safe default ```json {"when": {">": [{"var": ["booking.commission", 0]}, 15]}, "then": [{"reject": {"message": "Commission above 15 is not accepted."}}]} ``` Reading: `{"var": [path, default]}` replaces a missing or null fact by the default (here 0). Without the default a null `booking.commission` makes `>` a type clash and the rule is skipped. ### Pattern 8 - Boolean combination ```json {"when": {"and": [ {">": [{"var": "booking.partySize"}, 10]}, {"!": {"var": "booking.hasNotes"}}, {"!": {"var": "booking.hasCustomerCompany"}}]}, "then": [{"reject": {"message": "Large parties must give notes or a company."}}]} ``` Reading: reject a party of more than 10 that carries neither notes nor a customer company. `and` / `or` / `!` need real booleans (no truthiness) and `and` / `or` stop at the first deciding operand. ### Pattern 9 - Between (inclusive) ```json {"when": {"<=": [5, {"var": "booking.partySize"}, 8]}, "then": [{"reject": {"message": "Parties of 5 to 8 must call the venue."}}]} ``` Reading: three operands chain: 5 <= partySize and partySize <= 8, both ends included. ### Pattern 10 - Actions: set and flag (an enquiry instead of a refusal) ```json {"when": {">=": [{"var": "booking.partySize"}, 12]}, "then": [ {"set": {"path": "booking.type", "value": "request"}}, {"set": {"path": "booking.enquiryReason", "value": "operatorRule"}}, {"flag": {"name": "large-party"}}]} ``` Reading: for 12 or more guests write `booking.type = request` and `booking.enquiryReason = operatorRule` (the booking becomes an enquiry, no error) and attach the annotation `large-party`. A `reject` action in a rule refuses the booking instead (patterns 1 to 9); use `set` and `flag` alone for the enquiry outcome. `set` is only allowed on the writable paths of the hook. #### Checks - cheat-sheet ```json [ {"pattern": 1, "input": {"partySize": 9}, "expect": {"applies": true}}, {"pattern": 1, "input": {"partySize": 8}, "expect": {"applies": false}}, {"pattern": 2, "input": {"product": "sunday-lunch"}, "expect": {"applies": true}}, {"pattern": 2, "input": {"product": "afternoon-tea"}, "expect": {"applies": false}}, {"pattern": 2, "input": {"product": null}, "expect": {"applies": false}}, {"pattern": 3, "input": {}, "expect": {"applies": true}}, {"pattern": 3, "input": {"packages": [["premium-bottomless-brunch", 1]]}, "expect": {"applies": false}}, {"pattern": 4, "input": {"partySize": 4, "packages": [["premium-bottomless-brunch", 3], ["standard-bottomless-brunch", 2]]}, "expect": {"applies": true}}, {"pattern": 4, "input": {"partySize": 4, "packages": [["premium-bottomless-brunch", 2], ["standard-bottomless-brunch", 2]]}, "expect": {"applies": false}}, {"pattern": 5, "input": {"categories": [1]}, "expect": {"applies": true}}, {"pattern": 5, "input": {"categories": [5]}, "expect": {"applies": false}}, {"pattern": 5, "input": {"categories": []}, "expect": {"applies": false}}, {"pattern": 6, "input": {"date": "2026-10-04"}, "expect": {"applies": true}}, {"pattern": 6, "input": {"date": "2026-10-01"}, "expect": {"applies": true}}, {"pattern": 6, "input": {"date": "2026-10-08"}, "expect": {"applies": false}}, {"pattern": 6, "input": {"date": "2026-09-30"}, "expect": {"applies": false}}, {"pattern": 7, "input": {"commission": 20}, "expect": {"applies": true}}, {"pattern": 7, "input": {"commission": 10}, "expect": {"applies": false}}, {"pattern": 7, "input": {"commission": null}, "expect": {"applies": false}}, {"pattern": 8, "input": {"partySize": 12}, "expect": {"applies": true}}, {"pattern": 8, "input": {"partySize": 12, "hasNotes": true}, "expect": {"applies": false}}, {"pattern": 8, "input": {"partySize": 8}, "expect": {"applies": false}}, {"pattern": 9, "input": {"partySize": 5}, "expect": {"applies": true}}, {"pattern": 9, "input": {"partySize": 8}, "expect": {"applies": true}}, {"pattern": 9, "input": {"partySize": 9}, "expect": {"applies": false}}, {"pattern": 9, "input": {"partySize": 4}, "expect": {"applies": false}}, {"pattern": 10, "input": {"partySize": 12}, "expect": {"applies": true, "writes": {"booking.type": "request", "booking.enquiryReason": "operatorRule"}, "flags": ["large-party"]}}, {"pattern": 10, "input": {"partySize": 4}, "expect": {"applies": false}} ] ``` ## Errors when a booking is rejected The booking endpoints answer HTTP 400 with a problem document (schema `BookingRuleViolationProblem`). `violations[]` lists **every** rejecting rule, `code` / `title` / `detail` describe the primary one, and `analyticsRuleId` / `analyticsRuleName` identify the primary rule. `violations[].ruleId` is the same UUID as `rules[].id` returned by this API. `violations[].effect` is the snake_case effect (`expression` for generic rules). Per-violation message text is not repeated inside `violations[]`: look the rule up (its `message`, or the default message in the effects table). Example (a party of 6 chose a Premium brunch for 2 and a Standard brunch for 4, while a `same_package` rule with `exclusive: true` covers the Premium brunch): ```json { "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", "title": "Same Package Violation", "status": 400, "detail": "If this package is chosen, everyone must have the same one and nothing else can be added.", "code": "BKRUL-N-001", "isRetryable": false, "violations": [ { "ruleId": "9c1d5e72-3b84-4f06-a2d9-17c4e8b05a36", "ruleName": "Premium brunch - whole table, no mixing", "effect": "same_package", "code": "BKRUL-N-001" } ], "analyticsRuleId": "9c1d5e72-3b84-4f06-a2d9-17c4e8b05a36", "analyticsRuleName": "Premium brunch - whole table, no mixing", "traceId": "0HNJ2BG2TU3BU:00000001" } ``` Retrying the same request will fail again (`isRetryable` is false): change the booking. `GET /booking-rules` itself answers its own refusals with the shared `ClientError` problem document, the same shape as every other endpoint: each refusal carries a catalog `code` (`BKRUL-N-112`, `BKRUL-N-113`, `VALID-N-001`, `OPRTR-N-002`, `PMAP-N-001`, `PMAP-N-004`, `VENUE-N-001`, `IDENT-N-002`, `IDENT-N-003`, `RATE-R-001`), `isRetryable` and `traceId`; the examples of each response show the exact body. The 401 is the standard bearer challenge and may have no JSON body. ## Worked examples "Today" in all examples is 2026-10-01 (Europe/London). All ids are fictitious. ### Example 1 - Terrace, party of more than 8, no pre-order (expression, rejected with BKRUL-N-100) Rule (`hook: booking.create`): ```json { "when": {"and": [ {"==": [{"var": "space.name"}, "Terrace"]}, {">": [{"var": "booking.partySize"}, 8]}, {"==": [{"var": "booking.itemCount"}, 0]} ]}, "then": [{"reject": {"message": "Parties of more than 8 on the Terrace must include a pre-order."}}] } ``` Request: `POST /venues/17|X9|9d3f0b6e7a1c4e58b2c4f1a60d7e8b93|b41e07c2d5a9/booking` with `{"partySize": 10, "date": "2026-11-14", "time": "19:00", "type": "book", "spaceId": 4412, ...}` and no `preorders`. `spaceId` 4412 is the space named "Terrace" at that venue. Facts used: `space.name` = "Terrace" (resolved from `spaceId`), `booking.partySize` = 10, `booking.itemCount` = 0 (no pre-order lines). Evaluation: `"Terrace" == "Terrace"` true, `10 > 8` true, `0 == 0` true, so `when` is true. **Outcome: rejected**, HTTP 400, `code: BKRUL-N-100`, `detail: "Parties of more than 8 on the Terrace must include a pre-order."`, `violations[0].effect = "expression"`. Variants: party of 8 gives `8 > 8` false (not rejected); party of 10 with one package line gives `itemCount` 1 (not rejected); **no `spaceId`**: `space.name` is null, `null == "Terrace"` is a type clash and the rule is *skipped* (the platform does not reject on the terrace rule), but do not rely on that: the operator's system then picks the area and other checks may apply. ### Example 2 - Partner categories under 7 days become enquiries (expression, no error) Rule (`hook: booking.create`, `priority: 100`), aimed at partner categories 1 and 2 through the rule's `when`: ```json { "when": {"and": [ {"some": [{"var": "partner.categoryIds"}, {"in": [{"var": ""}, [1, 2]]}]}, {"<": [{"var": "booking.daysAhead"}, 7]} ]}, "then": [ {"set": {"path": "booking.type", "value": "request"}}, {"set": {"path": "booking.enquiryReason", "value": "operatorRule"}} ] } ``` You are partner 482 in category 1 for operator 17 (`caller.categoryIdsByOperator["17"] = [1]`). Request: `date: "2026-10-04"`, `type: "book"`. Facts: `partner.categoryIds` = [1]; `booking.daysAhead` = 2026-10-04 minus 2026-10-01 = 3. Evaluation: `some` over [1]: `1 in [1, 2]` true; `3 < 7` true; `when` true. **Outcome: not rejected; the booking is created as an enquiry** (`type: request`), the writes being `booking.type = "request"` and `booking.enquiryReason = "operatorRule"`. With `date: "2026-10-08"`: `daysAhead` = 7, `7 < 7` is false, so the booking is confirmed normally (exactly the lead time is outside the window). With `partner.categoryIds` = [5]: `some` is false, no effect. ### Example 3 - Brunch packages may not exceed the party size (expression, rejected with BKRUL-N-100) Rule (`hook: booking.create`): ```json { "when": {">": [ {"sum": {"pluck": [ {"filter": [{"var": "booking.items"}, {"in": [{"var": "slug"}, ["premium-bottomless-brunch", "standard-bottomless-brunch"]]}]}, "quantity"]}}, {"var": "booking.partySize"} ]}, "then": [{"reject": {"message": "You cannot select more brunch packages than guests."}}] } ``` Request: `partySize: 4`, `preorders.packages`: the venue package whose slug is `premium-bottomless-brunch` with `quantity: 3` and the package whose slug is `standard-bottomless-brunch` with `quantity: 2`. Facts: `booking.partySize` = 4; `booking.items` = `[{"kind": "package", "slug": "premium-bottomless-brunch", "quantity": 3}, {"kind": "package", "slug": "standard-bottomless-brunch", "quantity": 2}]`. Evaluation: `filter` keeps both lines, `pluck` gives [3, 2], `sum` = 5, `5 > 4` true. **Outcome: rejected**, `BKRUL-N-100`, "You cannot select more brunch packages than guests.". With quantities 2 and 2: `4 > 4` false, accepted by this rule. Inside `filter`, `{"var": "slug"}` is the slug of the current line; `booking.partySize` is not reachable inside the predicate (it is used outside it). ### Example 4 - `same_package`, exclusive (fixed effect, rejected with BKRUL-N-001) Rule: `effect: same_package`, `params: {"exclusive": true, "wholeGroup": false}`, `appliesTo.items.packages` = [`premium-bottomless-brunch`], all venues, all products, all partners. Party size 6. Request A: premium x2 and standard x4. S = {premium}; the chosen lines in S: premium (one distinct item), but the standard line is outside S and `exclusive` is true. **Rejected** with `BKRUL-N-001`; this is the response shown above. Request B: premium x6 only, or premium x2 only: **accepted** (`wholeGroup` is false, so the quantity need not equal the party size). Request C: standard x6 only: **accepted** (no S item chosen, the rule is inert). Request D: premium x2 plus a mocktail package: rejected (the mocktail is outside S); with `exclusive: false` requests A and D would both be accepted (the lines outside S are ignored) and only choosing two distinct S items (possible when S lists two items) would be rejected. ### Example 5 - The final balance is the sum of the minimum-spend bands the booking crosses (expression at `payment`, no error) Rule (`hook: payment`, `priority: 100`): ```json { "when": {">": [{"var": "payment.bandCount"}, 0]}, "then": [{"set": {"path": "payment.finalBalance", "value": {"sum": {"pluck": [{"var": "payment.bands"}, "spendValue"]}}}}] } ``` The operator configured two minimum-spend bands on the space: "Early" from 18:00 to 19:59 at 50000 pence (GBP 500.00) and "Late" from 20:00 to 23:00 at 80000 pence (GBP 800.00). The booking already exists: 2026-10-05, start 19:00, duration 180 minutes, so it occupies [19:00, 22:00). You (or the venue) ask for its payment options. This is not something you send: the platform builds the facts from the stored booking and the space's policy. Facts (the `payment` root; the other roots are those of the stored booking): ```json { "payment": { "bands": [ {"label": "Early", "from": "19:00", "to": "20:00", "spendType": "minimum_spend", "spendValue": 50000, "hireFeeValue": null, "overlapMinutes": 60}, {"label": "Late", "from": "20:00", "to": "22:00", "spendType": "minimum_spend", "spendValue": 80000, "hireFeeValue": null, "overlapMinutes": 120} ], "bandCount": 2, "minimumSpend": 50000, "currency": "GBP" } } ``` Evaluation: `payment.bandCount` 2 > 0 is true; `pluck` of `spendValue` gives [50000, 80000]; `sum` = 130000, so the rule writes `payment.finalBalance = 130000`. **Outcome: not rejected, nothing to fix; the amount payable is GBP 1,300.00** (130000 pence). The payment options become "full minimum spend" 130000 and "25% deposit" 32500 (130000 x 0.25). Without the rule they would have been 50000 and 12500 (the minimum spend at the start time only). Read the figures from `getPaymentOptions`, never compute them yourself. Variants: a booking at 19:00 for 60 minutes occupies [19:00, 20:00), which only touches "Early" (a band that starts exactly when the booking ends is not touched): the sum is 50000, the same as today. If the space had only the "Late" band, a booking at 19:00 for 180 minutes starts in a gap: `payment.minimumSpend` is null, `payment.bands` holds "Late" alone (from 20:00 to 22:00) and the rule writes 80000. A single band that contains the start time gives the same figure as today. A rule written with `payment.bandCount > 1` does not match a single band. ## End-to-end walkthrough for an AI agent Goal: book The Rooftop Kitchen (operator 17) on the Terrace for 10 guests on 2026-10-04 (today is 2026-10-01), with a brunch pre-order. You are partner 482, category 1. **Step 1 - fetch the rules** for the venue and the operation: `GET /booking-rules?venueId=17%7CX9%7C9d3f0b6e7a1c4e58b2c4f1a60d7e8b93&hook=booking.create`. Suppose the operator has just five rules active; the named 200 example `walkthrough` is then your answer (5 rules, hook `booking.create`): | Rule id (prefix) | Effect | What it says | |---|---|---| | `9c1d5e72` | `same_package`, `exclusive: true` | If `premium-bottomless-brunch` is chosen, nothing else may be chosen. Product `bottomless-brunch`. | | `e07a4b19` | `require_deposit`, 10.00 per person | Carry a deposit of 10.00 x party size. Aimed at you (`partnerScope: you`). | | `3f2b8c1e` | `expression`, priority 100 | Reject: space `Terrace`, party over 8 and no pre-order line. | | `5a9e2c74` | `expression`, priority 100 | Partner category 1 or 2 and `daysAhead` below 7: the booking becomes an enquiry. | | `b8d3f6a0` | `expression`, priority 100 | Reject: brunch package quantities add up to more than the party size. Aimed at your categories. | **Step 2 - the booking you intend to send** (`POST /venues/17|X9|9d3f0b6e7a1c4e58b2c4f1a60d7e8b93|b41e07c2d5a9/booking`; the package ids are the venue-specific ids from `GET /venues`, whose aggregation slugs are `premium-bottomless-brunch` and `standard-bottomless-brunch`): ```json { "partySize": 10, "date": "2026-10-04", "time": "19:00", "type": "book", "spaceId": 4412, "preorders": {"packages": [{"id": "5de64c68ce9583458c447544", "quantity": 6}, {"id": "5de64c68ce9583458c447545", "quantity": 4}]} } ``` No `depositAmount` is sent. `spaceId` 4412 is the space named "Terrace". **Step 3 - derive the facts** (`howToObtain` of each fact): `booking.partySize` 10; `booking.date` "2026-10-04"; `booking.daysAhead` 3 (2026-10-04 minus 2026-10-01); `booking.type` "book"; `booking.enquiryReason` "none"; `booking.items` [premium x6, standard x4]; `booking.itemCount` 2; `booking.depositAmount` null; `partner.id` 482; `partner.categoryIds` [1]; `product.slug` "bottomless-brunch"; `space.name` "Terrace". **Step 4 - evaluate the fixed rules first** (expression rules only run if all fixed rules pass): - `9c1d5e72` same_package: S = {premium}. The premium line is chosen; the standard line is outside S and `exclusive` is true: VIOLATED, `BKRUL-N-001`. - `e07a4b19` require_deposit: required 10.00 x 10 = 100.00, collected 0: VIOLATED, `BKRUL-N-006`. - Primary error by precedence (`same_package` before `require_deposit`): `BKRUL-N-001`. Both are listed in `violations[]`. The expression rules are not evaluated at all. The platform would answer HTTP 400: ```json { "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", "title": "Same Package Violation", "status": 400, "detail": "If this package is chosen, everyone must have the same one and nothing else can be added.", "code": "BKRUL-N-001", "isRetryable": false, "violations": [ {"ruleId": "9c1d5e72-3b84-4f06-a2d9-17c4e8b05a36", "ruleName": "Premium brunch - whole table, no mixing", "effect": "same_package", "code": "BKRUL-N-001"}, {"ruleId": "e07a4b19-6c2d-48f3-b5a1-3d9c70f2e864", "ruleName": "Deposit of 10 pounds per person", "effect": "require_deposit", "code": "BKRUL-N-006"} ], "analyticsRuleId": "9c1d5e72-3b84-4f06-a2d9-17c4e8b05a36", "analyticsRuleName": "Premium brunch - whole table, no mixing", "traceId": "0HNJ2BG2TU3BU:00000001" } ``` **Step 5 - change the booking**: choose premium x10 only (no standard line) and collect 100.00, then send `"depositAmount": 100.00`. **Step 6 - evaluate again.** Fixed rules: `9c1d5e72`: the only chosen line is in S and `wholeGroup` is false: passes. `e07a4b19`: 100.00 + 0.005 >= 100.00: passes. Expression rules run now, in order (all priority 100, so by rule id): - `3f2b8c1e`: `space.name` "Terrace" equals "Terrace" (true); 10 > 8 (true); `booking.itemCount` is 1, not 0 (false): `when` is false, nothing happens. - `5a9e2c74`: partner category 1 is in [1, 2] (true) and `daysAhead` 3 < 7 (true): `when` is true, so `booking.type` = "request" and `booking.enquiryReason` = "operatorRule". - `b8d3f6a0`: filter keeps premium x10, `pluck` gives [10], `sum` 10; 10 > 10 is false: nothing happens. **Decision:** the booking is accepted and created as an **enquiry** (type `request`, no error): the operator has to accept it. To get an instant confirmation instead, book on 2026-10-08 or later (`daysAhead` 7, and 7 < 7 is false, so `5a9e2c74` does not apply). #### Scenarios - end to end (fixed rules) ```json [ {"rule": "9c1d5e72", "partySize": 10, "date": "2026-10-04", "packages": [["premium-bottomless-brunch", 6], ["standard-bottomless-brunch", 4]], "expect": {"violation": "BKRUL-N-001", "message": "If this package is chosen, everyone must have the same one and nothing else can be added."}}, {"rule": "e07a4b19", "partySize": 10, "date": "2026-10-04", "packages": [["premium-bottomless-brunch", 6], ["standard-bottomless-brunch", 4]], "expect": {"violation": "BKRUL-N-006", "message": "Please collect the 10 pound per person deposit and send it as depositAmount."}}, {"rule": "9c1d5e72", "partySize": 10, "date": "2026-10-04", "packages": [["premium-bottomless-brunch", 10]], "expect": {"violation": null}}, {"rule": "e07a4b19", "partySize": 10, "date": "2026-10-04", "packages": [["premium-bottomless-brunch", 10]], "deposit": 100.00, "expect": {"violation": null}} ] ``` #### Checks - end to end (expression rules) ```json [ {"rules": ["3f2b8c1e", "5a9e2c74", "b8d3f6a0"], "input": {"partySize": 10, "date": "2026-10-04", "space": "Terrace", "packages": [["premium-bottomless-brunch", 10]]}, "expect": {"rejects": false, "applied": ["5a9e2c74"], "writes": {"booking.type": "request", "booking.enquiryReason": "operatorRule"}}}, {"rules": ["3f2b8c1e", "5a9e2c74", "b8d3f6a0"], "input": {"partySize": 10, "date": "2026-10-08", "space": "Terrace", "packages": [["premium-bottomless-brunch", 10]]}, "expect": {"rejects": false, "applied": [], "writes": {}}}, {"rules": ["3f2b8c1e", "5a9e2c74", "b8d3f6a0"], "input": {"partySize": 10, "date": "2026-10-08", "space": "Terrace"}, "expect": {"rejects": true, "applied": ["3f2b8c1e"], "writes": {}}} ] ``` The third check shows why step 5 matters: with the same party and no pre-order line, the Terrace rule `3f2b8c1e` would reject (`BKRUL-N-100`). ## Common mistakes 1. **Using ids instead of slugs.** Rules identify products, packages and menus by their **aggregation slug** (`slug` in `GET /venues` and in `appliesTo.products[]` / `appliesTo.items`), never by the venue-specific `id` you send in `preorders`, and never by the `id` fields of this API (those are operator-side catalog ids, informational only). 2. **Forgetting that items without a slug are invisible.** A pre-order line whose package or menu has no aggregation slug is dropped before any rule runs: it neither triggers nor satisfies a rule, and a rule with a product list never applies to a product that has no slug. 3. **Reading `booking.partySize` (or any other fact) inside a list predicate.** Inside `filter`, `map`, `some`, `all`, `none`, `{"var": "x"}` reads the field of the CURRENT element; the rest of the facts are not reachable there, so the path resolves to null and the comparison clashes. Compute such comparisons outside the predicate (pattern 4). 4. **Assuming skipped means failed.** A rule that cannot be evaluated (a null fact where an operator needs a number, a type clash, a division by zero) is SKIPPED: it does not apply and it never refuses the booking. A skip also does not mean you are compliant: the operator's system or other checks may still refuse. Do not use a skip to "pass" a rule; send the fact (for example `spaceId`) instead. 5. **Assuming an amend only evaluates what you changed.** An amend is evaluated on the FINAL state of the booking against every applicable rule: a contact-only amend of a booking that already violates a rule can be rejected. For `lock_preorder_edits` the comparison is final lines against stored lines. 6. **Expecting expression violations together with fixed ones.** Expression rules run only after every fixed rule passed. Fix the fixed violations first, then expect expression rules to speak. 7. **Off-by-one on the boundaries.** `preorder_required` is inclusive (party size >= `threshold`); `route_to_enquiry` and `lock_preorder_edits` are exclusive (`daysAhead < leadTimeDays`; exactly `leadTimeDays` ahead is outside); `max_quantity` and `max_quantity_party_size` allow equality; deposits tolerate half a penny. 8. **Using UTC for `daysAhead`.** It is counted in whole calendar days of the Europe/London date, time of day ignored. 9. **Forgetting `whenApplied`.** `route_to_enquiry` never runs on an amend, an expression rule at `booking.amend` never runs on create, and a rule at the `payment` hook (`booking_payment`) runs on neither: it runs only when the amount payable of an existing booking is computed. Keep only rules whose `whenApplied` contains your operation. 10. **Parsing `summary`, `description` or default messages.** They are English text that can change. Branch on `effect`, `params`, `errorCode` and `outcomes`. 11. **Treating the literals in `when` as facts about you.** A number compared with `partner.id` or `partner.categoryIds` is whatever the operator wrote; use `caller` for your own values and `appliesTo.partnerScope` for how the rule is aimed at you. 12. **Using `in` on a string as a membership test.** With a string haystack `in` is an ordinal substring test (`{"in": ["a", "banana"]}` is true); membership needs a list haystack. 13. **Caching the answer across bookings.** Rules can change at any time; the 400 of the booking endpoint is the final word. 14. **Evaluating a `payment` rule yourself.** You cannot rebuild `payment.bands` from your booking request, and a `payment` rule never refuses a booking (a `reject` there is ignored). Use it to understand why the amount payable may be the sum of several bands, then read the amount from `GET /venues/bookings/{bookingId}/payment-options` (`getPaymentOptions`). #### Scenarios - mistakes with fixed effects ```json [ {"rule": "9c1d5e72", "partySize": 6, "date": "2026-11-14", "create": false, "original": {"packages": [["premium-bottomless-brunch", 2], ["standard-bottomless-brunch", 4]]}, "packages": [["premium-bottomless-brunch", 2], ["standard-bottomless-brunch", 4]], "expect": {"violation": "BKRUL-N-001", "message": "If this package is chosen, everyone must have the same one and nothing else can be added."}} ] ``` The scenario above is mistake 5: nothing about the pre-order changes, yet the amend is evaluated on the final state and violates the rule. #### Checks - mistakes with expressions ```json [ {"rule": {"when": {"some": [{"var": "booking.items"}, {">": [{"var": "quantity"}, {"var": "booking.partySize"}]}]}, "then": [{"reject": {"message": "A line is larger than the party."}}]}, "input": {"partySize": 2, "packages": [["premium-bottomless-brunch", 3]]}, "expect": {"applies": "skipped"}}, {"rule": {"when": {"==": [{"var": "space.name"}, "Terrace"]}, "then": [{"reject": {"message": "No Terrace."}}]}, "input": {"partySize": 4}, "expect": {"applies": "skipped"}}, {"rule": {"when": {"in": ["a", "banana"]}, "then": [{"flag": {"name": "substring"}}]}, "input": {"partySize": 4}, "expect": {"applies": true}}, {"rule": {"when": {">": [{"var": "booking.commission"}, 15]}, "then": [{"reject": {"message": "Commission above 15 is not accepted."}}]}, "input": {"partySize": 4, "commission": null}, "expect": {"applies": "skipped"}} ] ``` The first check is mistake 3 (`booking.partySize` inside the predicate: the rule is skipped, not true and not false), the second is mistake 4 (no `spaceId`, so `space.name` is null and the comparison with a string is a type clash), the third is mistake 12, and the fourth is a null fact without a default (pattern 7 shows the null-safe form). ## What is not guaranteed - Results are a pre-check; the platform may reject for reasons not described here (availability, other validation). - Pre-order lines whose package or menu has no aggregation slug are invisible to every rule (the platform drops them before evaluating), so they never trigger nor satisfy a rule. - An amend is evaluated on the **final** state of the booking against every applicable rule, whether or not the amend touches the pre-order: a contact-only amend of a booking that already violates a rule can be rejected. - `summary`, `description` and default messages are English text generated or written by the operator; do not parse them. - Whether the platform evaluates a `PUT` amend with the pre-order lines stored on the booking or with none (the PUT request body has no `preorders` field) is not verified; prefer `PATCH` when you change pre-orders. - A `payment` rule is documented from the platform's code and unit tests. Availability lists the minimum-spend bands a slot crosses (each with its own value, no total) and `GET /venues` shows the configured rules, so neither is the amount payable. Whether any particular operator has configured a `payment` rule is only visible in this API's answer, and the amount to pay is only authoritative in the payment options response. Version: 1.6.0 ## Servers Live ``` https://api.bookabletech.com ``` Sandbox ``` https://api-sandbox.bookabletech.com ``` ## Security ### Live This API uses OAuth2 client credentials flow via Auth0. The calling partner is identified from the token. **Important:** Include the `audience` parameter in your token request. Example token request: ``` POST https://auth.bookabletech.com/oauth/token { "grant_type": "client_credentials", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "audience": "api.bookabletech.com" } ``` Type: oauth2 ### Sandbox OAuth2 client credentials flow via Auth0 (sandbox tenant). The calling partner is identified from the token. Type: oauth2 ## Download OpenAPI description [Partner Booking Rules API](https://docs.bookabletech.com/_bundle/apis/production/PartnerBookingRulesApi.yaml) ## PartnerBookingRules Read the booking condition rules that are active for the calling partner. ### List the booking condition rules that are active for the calling partner - [GET /booking-rules](https://docs.bookabletech.com/apis/production/partnerbookingrulesapi/partnerbookingrules/getpartnerbookingrules.md): Returns every active booking condition rule that applies to the calling partner, across all the operators (venue groups) the partner is linked to (the same operators GET /venues exposes), together with the description of every hook referenced by an expression rule in the response (hooks[]: its facts, how to obtain each fact from your booking request, and its writable paths). A rule is returned when it is active and it has no partner target, or it targets the calling partner, or it targets one of the calling partner's categories. Rules aimed only at other partners are never returned, and no other partner is identified: appliesTo.partnerScope says whether the rule is aimed at everybody (all_partners), at you (you) or at a category you belong to (your_categories). appliesTo.venues[] lists only the venues you can see (the same visibility as GET /venues). A rule restricted to venues none of which you can see is not returned; allVenues: false with a non-empty venues[] means "only these of your venues". The partner is taken from the credentials (or from X-Partner-Reference for Bookable Agents, as on the other partner endpoints). There is no operator in the path. The response is not paginated. Filters narrow the response and are combined with AND: - operatorId: only rules of that operator (venue group). - venueId: only rules that apply to that venue. A rule with no venue target applies to every venue; a rule with venue targets is returned only if it lists that venue. - effect: only rules with that effect. - hook: only rules that apply at that moment of the flow: booking.create returns the rules whose whenApplied contains booking_create (fixed rules that run on create and expression rules at the booking.create hook), booking.amend those whose whenApplied contains booking_amend, payment those whose whenApplied contains booking_payment (only expression rules at the payment hook: they change the amount payable for an existing booking, see "Rules at the payment hook" in the API description). rules[] is ordered by operatorId, then fixed effects (by the precedence order in the guide: lock_preorder_edits, preorder_required, same_package, max_quantity_party_size, max_quantity, require_deposit, then route_to_enquiry, which never rejects; rules of one effect by rule id), then expression rules by hook (booking.create, then booking.amend, then payment), priority ascending (no priority counts as 100) and rule id, which is the order the platform evaluates expression rules. Properties whose value is null are omitted from the JSON; arrays and objects that the schema declares required are always present, empty when there is nothing to list. Read the "Integration guide for AI agents", the effects table, the expression language reference and the worked examples in the API description first. Responses: | Status | Meaning | Error code | |---|---|---| | 200 | The rules (possibly none) and the hooks they reference. | | | 400 | Unknown hook, operatorId below 1, malformed venueId, venueId of another operator than operatorId, or a query value of the wrong type. | none, or VALID-N-001 for a wrong type | | 401 | Missing, expired or invalid bearer token. | none | | 403 | Operator or venue not available to you, or unknown X-Partner-Reference. | none, IDENT-N-002, IDENT-N-003 | | 404 | venueId does not exist. | VENUE-N-001 | | 429 | Rate limit exceeded: retry after Retry-After. | RATE-R-001 |