Read the booking condition rules that are active for the calling partner.
Partner Booking Rules API (1.6.0)
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.
# 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 |
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.
- Fetch the rules. Call
GET /booking-rules. Optional filters:operatorId(one operator),venueId(only rules that apply to that venue),effect,hook. Readcaller(your partner id and categories per operator),rules[]andhooks[]. The list is not paginated: you get the whole active set that applies to you. - Keep the rules that can concern your booking, using
rules[].appliesTo:- venue:
allVenuesis true, or the first three segments of the 4-segmentcompositeIdyou book ({operatorId}|{rmsSlug}|{venueResourceId}) equal one ofvenues[].id(venues[]lists only venues you can see; a rule restricted to venues you cannot see is not returned). WithvenueIdthe server already did this. - product (booking type):
allProductsis true, or theslugof the product you book (theslugfield of the product inGET /venues) equals one ofproducts[].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").partnerScopealready 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_createfor create,booking_amendfor PUT/PATCH).booking_paymentis 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 fromGET /venues/bookings/{bookingId}/payment-options(operationIdgetPaymentOptionsof the Payment API).
- venue:
- Fixed effects (every effect except
expression): apply the exact semantics in the effects table below to the booking you intend to send, usingparams,appliesTo.itemsand the pre-order lines. - Expression rules (
effect: expression): pick the rules whosehookmatches the operation (booking.createfor create,booking.amendfor amend; the rules of hookpaymentare not evaluated by you, see step 2 and "Rules at thepaymenthook"). Look the hook up inhooks[]. Build the fact document (a JSON object with the top-level keysbooking,partner,product,venue,space) by followinghooks[].facts[].howToObtainfor every fact. Evaluatewhenagainst it using the language reference below. Evaluate the matching rules in the order they appear inrules[](priority ascending, then rule id); asetwritten by an earlier rule is visible to the later rules. - Interpret the outcome. If
whenis true, thethenactions apply;rules[].outcomesalready lists them so you need not parsethen:rejects(the booking will be refused withBKRUL-N-100andrejectMessages),writes(values the platform will write, e.g.booking.type = requestmakes the booking an enquiry; at thepaymenthook the only write ispayment.finalBalance, the amount payable) andflags(named annotations without any behaviour). Ifwhenis 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. - Change the booking (party size, date, space, pre-order lines, deposit) until no rule rejects it, or accept the enquiry outcome.
- When you are rejected, read
violations[]of the 400 response and match eachviolations[].ruleIdwithrules[].idfrom this API: it is the same id. Then fix what that rule says (summary,message,params). - 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.
| 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": "<path>"} (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 | 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). |
| 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 ' |
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.
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.
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.
- 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, thenpreorder_required, thensame_package, thenmax_quantity_party_size, thenmax_quantity, thenrequire_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_enquiryhas already setbooking.type = requestandbooking.enquiryReason = operatorRulewhen the expression rules run, so expressions see those values. - Expression rules of one hook run by
priorityascending (default 100), then by rule id.rules[]is returned in that order: expression rules ofbooking.create, then ofbooking.amend, then ofpayment. - Rules of the
paymenthook run at their own moment (see the next section), never together with the fixed rules or with the rules of the booking hooks. - Several
setactions on one path: they compose in that order and the last one wins. Every later rule (itswhenand 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
setvalue 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
whenthat is not boolean, asetto a path that is not writable at the hook, or a limit overrun.
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(operationIdgetPaymentOptions, Payment API) and when you callPOST /venues/bookings/{bookingId}/payments(operationIdcreatePayment). It is not evaluated at booking create or amend, and it does not change what availability orGET /venuesshow: availability lists each minimum-spend band the slot's interval [start, start + duration) crosses, each with its own value and no total, andGET /venuesshows 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
paymentrule 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 withlabel,from,to,spendType,spendValuein pence,hireFeeValuein pence andoverlapMinutes),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) andpayment.currency(alwaysGBP). Thebooking.*,partner.*,product.*,venue.*andspace.*facts are those of the stored booking;booking.itemsandbooking.originalItemsare always empty,booking.isCreateis false, andbooking.type,booking.enquiryReasonandbooking.isCrossSaleare 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
createPaymentcannot create the payment. - What has no effect. A
rejectaction at this hook is ignored by the platform (it only logs a warning), sooutcomes.rejectsis always false for apaymentrule anderrorCodeis omitted. Aflaghas 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 apaymentrule 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.
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.
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
exclusiveis 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 (
wholeGroupis false, so the quantity need not equal the party size 6). - Passing: standard x6. No S line is chosen, the rule is inert.
[
{"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}}
]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.
[
{"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}}
]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.
[
{"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}}
]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).
[
{"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}}
]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).
[
{"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}}
]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.
daysAhead3 < 7. - Enquiry: date 2026-10-07.
daysAhead6 < 7. - Confirmed normally: date 2026-10-08.
daysAhead7 and 7 < 7 is false: exactlyleadTimeDaysahead is outside the window. - Not affected: an amend of a booking dated 2026-10-04 (the effect is create only).
[
{"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}}
]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 (
daysAhead3) 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 (
daysAhead7, 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.
[
{"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}}
]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-006with the rule's own message. - Passing:
depositAmount60.00. - Violating:
depositAmount59.99. 59.99 + 0.005 = 59.995 < 60.00. - Violating: party 8 with
depositAmount60.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,depositAmount99.99:BKRUL-N-006, "A deposit of £100.00 is required for this booking (£100.00 per booking)." - Passing:
depositAmount100.00 (party size is irrelevant forper: booking). - Passing: product
bottomless-brunchwith no deposit (the rule is limited tosunday-lunch, so it does not apply). - Passing (amend): the stored deposit is 100.00.
[
{"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}}
]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).
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 andnone(booking.items, slug == premium)is true, sowhenis 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,
someis false.
[
{"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": []}}
]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": "<path>"} reads a fact by dotted path (object members by name, list elements by zero-based index); {"var": ["<path>", <default>]} supplies a default for a missing or null fact; {"var": ""} is the current element inside a list predicate; or {"<operator>": [<args>]} (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": [<list>, <predicate>]}. 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": [<list>, "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.
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.
{"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).
{"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.
{"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.
{"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.
{"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]).
{"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.
{"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.
{"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.
{"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.
{"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.
[
{"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}}
]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):
{
"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.
Rule (hook: booking.create):
{
"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.
Rule (hook: booking.create, priority: 100), aimed at partner categories 1 and 2 through the rule's when:
{
"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.
Rule (hook: booking.create):
{
"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).
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.
Rule (hook: payment, priority: 100):
{
"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):
{
"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.
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):
{
"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):
9c1d5e72same_package: S = {premium}. The premium line is chosen; the standard line is outside S andexclusiveis true: VIOLATED,BKRUL-N-001.e07a4b19require_deposit: required 10.00 x 10 = 100.00, collected 0: VIOLATED,BKRUL-N-006.- Primary error by precedence (
same_packagebeforerequire_deposit):BKRUL-N-001. Both are listed inviolations[]. The expression rules are not evaluated at all.
The platform would answer HTTP 400:
{
"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.itemCountis 1, not 0 (false):whenis false, nothing happens.5a9e2c74: partner category 1 is in [1, 2] (true) anddaysAhead3 < 7 (true):whenis true, sobooking.type= "request" andbooking.enquiryReason= "operatorRule".b8d3f6a0: filter keeps premium x10,pluckgives [10],sum10; 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).
[
{"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}}
][
{"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).
- Using ids instead of slugs. Rules identify products, packages and menus by their aggregation slug (
sluginGET /venuesand inappliesTo.products[]/appliesTo.items), never by the venue-specificidyou send inpreorders, and never by theidfields of this API (those are operator-side catalog ids, informational only). - 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.
- Reading
booking.partySize(or any other fact) inside a list predicate. Insidefilter,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). - 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. - 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_editsthe comparison is final lines against stored lines. - 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.
- Off-by-one on the boundaries.
preorder_requiredis inclusive (party size >=threshold);route_to_enquiryandlock_preorder_editsare exclusive (daysAhead < leadTimeDays; exactlyleadTimeDaysahead is outside);max_quantityandmax_quantity_party_sizeallow equality; deposits tolerate half a penny. - Using UTC for
daysAhead. It is counted in whole calendar days of the Europe/London date, time of day ignored. - Forgetting
whenApplied.route_to_enquirynever runs on an amend, an expression rule atbooking.amendnever runs on create, and a rule at thepaymenthook (booking_payment) runs on neither: it runs only when the amount payable of an existing booking is computed. Keep only rules whosewhenAppliedcontains your operation. - Parsing
summary,descriptionor default messages. They are English text that can change. Branch oneffect,params,errorCodeandoutcomes. - Treating the literals in
whenas facts about you. A number compared withpartner.idorpartner.categoryIdsis whatever the operator wrote; usecallerfor your own values andappliesTo.partnerScopefor how the rule is aimed at you. - Using
inon a string as a membership test. With a string haystackinis an ordinal substring test ({"in": ["a", "banana"]}is true); membership needs a list haystack. - Caching the answer across bookings. Rules can change at any time; the 400 of the booking endpoint is the final word.
- Evaluating a
paymentrule yourself. You cannot rebuildpayment.bandsfrom your booking request, and apaymentrule never refuses a booking (arejectthere is ignored). Use it to understand why the amount payable may be the sum of several bands, then read the amount fromGET /venues/bookings/{bookingId}/payment-options(getPaymentOptions).
[
{"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.
[
{"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).
- 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,descriptionand default messages are English text generated or written by the operator; do not parse them.- Whether the platform evaluates a
PUTamend with the pre-order lines stored on the booking or with none (the PUT request body has nopreordersfield) is not verified; preferPATCHwhen you change pre-orders. - A
paymentrule 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) andGET /venuesshows the configured rules, so neither is the amount payable. Whether any particular operator has configured apaymentrule is only visible in this API's answer, and the amount to pay is only authoritative in the payment options response.