> For the complete Lynkow documentation index in agent-friendly format, see [llms.txt](/llms.txt).

# Book a product reservation slot

**Publié le** : 2026-08-19
**Catégorie** : Commerce

## `POST /products/:idOrSlug/bookings`

**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.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `idOrSlug` | path | string | Yes |  |
| `locale` | query | string | No | Optional 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 Body

Content-Type: `application/json`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `startsAt` | string | Yes | Required. Start datetime (ISO 8601) |
| `partySize` | integer | Yes | Required. Number of guests included in the reservation. Between 1 and 50 |
| `guestName` | string | Yes | Required. Guest name captured for the reservation. 1-200 characters |
| `guestEmail` | string | Yes | Required. Guest email captured for the reservation. Valid email address. Max 320 characters |
| `guestPhone` | string | No | Optional guest phone captured for the reservation. 3-40 characters |
| `resourceId` | string | No | Reservation calendar identifier (bres_ prefixed id); omit it to let the server assign a free calendar. 1-200 characters |
| `offeringId` | string | No | Reservation offering identifier (roff_ prefixed id). 1-200 characters |
| `locale` | any | No | Optional 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. |


### Responses

| Status | Description |
| --- | --- |
| `201` | Successful response |
| `401` | Missing or invalid storefront publishable key |
| `402` | The site subscription does not currently permit this operation |
| `403` | The storefront key origin is not allowed |
| `404` | Not found |
| `422` | Validation error |


---