Book a product reservation slot
Creates a booking for a PUBLISHED bookable product, scoped to the `:siteId` in the URL, served behind a required storefront publishable key and guarded by spam protection (honeypot + timestamp). A guest supplies the slot `startsAt`, `partySize`, and their contact (`guestName`, `guestEmail`, optional `guestPhone`). An optional `offeringId` selects a live formula's duration, buffers, price, and immutable attribution while `reservations_offerings_storefront` is on. A bad id is 422 `OFFERING_NOT_FOUND`; a configured menu with no selection is 422 `OFFERING_REQUIRED`; and a selected formula whose party-size bounds are exceeded is 422 `PARTY_SIZE_OUT_OF_RANGE`. Offerings whose minimum party size exceeds the storefront party maximum are excluded from this surface: such an id is 422 `OFFERING_NOT_FOUND` (not selectable), and a product whose ONLY live offerings all exceed the cap does NOT demand a selection (no `OFFERING_REQUIRED`), falling back to the product-level booking. With the offering flag off, the id is ignored and the shipped product-level grid and price remain available. The server derives `endsAt` and the persisted price; no client price field is accepted. When the product requires a deposit, the payments slice is live, and the site has a connected payment account, the shared booking service snapshots the flat total and derived deposit and atomically creates a pending booking plus its payment. This controller then returns a hosted checkout URL and short-lived payment capability; capture confirms the booking. A no-deposit product remains immediately confirmed. A deposit-required product with the payments slice live but no connected account follows the confirmed on-site fallback with a merchant-visible warning; the same on-site fallback applies when a connected account exists but the derived deposit is below the payment provider's minimum chargeable amount (carrying a distinct below-minimum warning), so a sub-minimum deposit never blocks the booking. With the payments slice off, the existing on-site path is unchanged. An AUTHENTICATED buyer (a valid customer session token, resolved by the optional `storefrontCustomerAuth` middleware) instead books with their account contact pre-filled: the booking is linked to their customer record and the stored account email is always used, so the identity email cannot be spoofed onto another record. The stored NAME and PHONE are pre-filled only once the buyer's email is verified; until then the submitted `guestName` / `guestPhone` are used, so a session that has not proven inbox control is never handed the account holder's stored profile. The submitted values also remain the fallback for a stored field the account lacks. When the storefront's `account_mode` is `accounts_required` AND the `customer_accounts` buyer-auth surface is live for the site (commerce enabled and the kill-switch not forced off, the same expression that gates the register/login routes), a request with no authenticated customer is rejected 403 `ACCOUNT_REQUIRED` BEFORE any capacity check or write; with `customer_accounts` killed off (no way to register or log in) the site falls back to guest behavior so bookings are never bricked. This auth-first ordering deliberately runs before product resolution: the uniform 403 cannot disclose whether a product exists. When the `reservations_resource_pools` flag is on and the product has several interchangeable calendars, the buyer MAY name an optional `resourceId` (a `bres_...` wire id from the resource listing) in `customer_optional` mode: a chosen FREE resource is booked, a chosen FULL one is rejected 422 with NO silent reassignment, and a resource that is not a live member of the pool is 422 `RESOURCE_NOT_IN_POOL`. The field is honored ONLY in that mode with the slice live: on an `auto` product, or with the slice off, a submitted `resourceId` is IGNORED rather than rejected and the server auto-assigns as if it had been omitted. Omitting `resourceId` auto-assigns a free resource by a deterministic first-free order (ADR-0038 decision F, under which an `auto` product's resources are not exposed and the system always auto-assigns). With the flag off (or a single-calendar product) the booking targets the single calendar, byte-for-byte as before. The capacity check + insert run inside one per-resource transaction guarded by an advisory lock, so concurrent buyers racing for the last seat can never exceed capacity, and two concurrent bookings on a pool land on DISTINCT resources. The server re-validates `startsAt` against the computed open slots, so an arbitrary or past instant is rejected 422 `SLOT_UNAVAILABLE` (never trusting the client). A full slot is 422 `CAPACITY_EXCEEDED`. A missing / draft / archived / cross-tenant product, and a non-`bookable` product, all return an identical 404 (no existence oracle). On success it returns 201 with a guest-safe confirmation (a `bkg_` reference, status, the slot instants, party size, the buyer's own name / email, the server-derived `priceCents`, the selected offering pair when present, the nested deposit checkout result only when online payment is required, and, in `customer_optional` mode only, the booked `resource` id + name; no other internal or cross-booking field) and emits `reservation.booked` (audit + outbox webhook) after commit so the merchant is notified. An optional `locale` is a language hint for the buyer-facing welcome email described below. Strings are truncated to 64 characters and non-strings are treated as omitted, so this secondary presentation choice never rejects an otherwise valid booking. Region-tagged hints are narrowed to a supported buyer-mail bundle. Omitted, empty, or non-string hints compose with the site's default locale; an explicit unsupported string resolves to English instead of falling through to that default (ADR-0051). The hint may be sent in the body or query string; the query string wins when both provide it. It is not stored on the booking and never appears in the response. When the booking creates the site's customer record for this buyer, it also queues ONE localized welcome email in the SAME transaction (commerce slice G1, ADR-0050), so a booking that rolls back takes the queued welcome with it. The mail itself leaves out of band: no transport call is made on this request and no provider failure can reach this response. The QUEUING is synchronous inside the booking transaction, so it does add a render, an encrypt and two inserts to this route's work, and a database fault there fails the booking rather than being swallowed, which is the atomicity this slice is specified to have. The welcome is sent at most once per customer for the lifetime of the relationship: a buyer who registered but has not activated yet has that pending welcome promoted here instead of receiving a second one, and a returning, authenticated, admin-created, imported, or checkout-created customer gets none. The `customer_welcome_email` kill-switch, or the `customers` kill-switch that stops linking altogether, disables the whole side effect with no other change to this route. > **Note:** This endpoint requires the e-commerce feature to be enabled on your site.
/products/{idOrSlug}/bookingshttps://api.lynkow.com/storefront/{siteId}Path parameters
idOrSlugstringrequired
Query parameters
localestringOptional buyer-email language hint. String values are truncated to 64 characters; non-string values are treated as omitted. Exact en, fr, es, de, or pt values are kept and regional variants narrow to their base language. An explicit unsupported string resolves to English; omitted, empty, or non-string values use the site's default locale and resolve it by the same rule. When both the query string and JSON body provide locale, the query value wins.
Request bodyrequired
localeanyOptional buyer-email language hint. String values are truncated to 64 characters; non-string values are treated as omitted. Exact en, fr, es, de, or pt values are kept and regional variants narrow to their base language. An explicit unsupported string resolves to English; omitted, empty, or non-string values use the site's default locale and resolve it by the same rule. When both the query string and JSON body provide locale, the query value wins.
startsAtstringrequiredRequired. Start datetime (ISO 8601)
guestNamestringrequiredRequired. Guest name captured for the reservation. 1-200 characters
partySizeintegerrequiredRequired. Number of guests included in the reservation. Between 1 and 50
guestEmailstringrequiredRequired. Guest email captured for the reservation. Valid email address. Max 320 characters
guestPhonestringOptional guest phone captured for the reservation. 3-40 characters
offeringIdstringReservation offering identifier (roff_ prefixed id). 1-200 characters
resourceIdstringReservation calendar identifier (bres_ prefixed id); omit it to let the server assign a free calendar. 1-200 characters