Storefront API
The Storefront API v1 is the only data a storefront theme receives (ADR-013). It is served on each shop’s host at /storefront-api/v1, the subdomain and, once live, the shop’s own domain (ADR-022); the shop is always the host’s, resolved by the same IdentifyTenant middleware as the rest of the app. The contract is docs/storefront-api/openapi.yaml in the repository; the SDK for themes is generated from it.
Endpoints
Section titled “Endpoints”| Endpoint | Returns |
|---|---|
GET /shop |
Name, slug, domain (the one host customers use: the shop’s own domain when live, otherwise the subdomain; the runtime redirects other hosts there), currency, timezone, default language, the shop’s public profile and the published theme configuration (themes and configuration). With ?preview=<token>, it returns the draft instead, marked preview: true and never cached. demo: true while the shop shows the demo shop: themes say so on every page, and orders placed are marked test orders. open: false while the shop has no active catalogue bouquet and no active flower: the runtime then shows “opening soon” (runtime) |
GET /categories |
Categories that contain active products, in the shop’s order, with counts |
GET /occasions |
Occasions that tagged catalogue bouquets carry, with counts |
GET /bouquets |
Catalogue bouquets. Bouquet cards carry flower_names (most stems first, at most four) for a composition line. Filters: category, occasion, ids[] (chosen bouquets), q (case-insensitive), sort (newest, price_asc, price_desc), available. 24 per page |
GET /bouquets/{id} |
One catalogue bouquet with its gallery and composition. Custom, inactive and other shops’ bouquets return 404 |
GET /flowers, GET /flowers/{id} |
Flowers sold by the stem for the customer’s own bouquet |
GET /packagings |
Packaging options |
GET /cart |
The visitor’s cart. Without a cart cookie this is an empty cart, and reading never creates one |
POST /cart/items |
Add a catalogue bouquet; creates the cart on first use |
POST /cart/custom-bouquets |
Add the customer’s own bouquet, priced exactly from its stems |
PATCH /cart/items/{id}, DELETE /cart/items/{id} |
Change or remove a line of the visitor’s own cart |
GET /checkout |
How the shop takes orders now: pickup (with its address), delivery (city, fee, free-from amount), payment methods, and a calendar per method of the dates and windows offered, in the shop’s timezone (pickup cut to the opening hours). enabled: false while checkout is off (checkout) |
GET /checkout/uklon/addresses |
Uklon’s address search in the shop’s Uklon city, for the Uklon courier option (checkout) |
POST /checkout/quote |
The visitor’s cart priced from the catalogue with a fulfilment choice: lines, packaging, delivery fee, total, a fingerprint and its ten-minute expiry |
POST /checkout |
Send the cart as an order request with the confirmed quote’s fingerprint. 201 with the order number and private status link; 200 with the same answer on a repeat; 409 with the current quote when it expired or changed |
GET /orders/{token} |
The private status link: status, items, totals, fulfilment and payment of one order |
What it shows and hides
Section titled “What it shows and hides”Public fields only. Each response is built by an explicit resource (App\Http\Resources\StorefrontApi\V1) that lists its fields. The contract test rejects any field the specification does not declare, so a model attribute added later cannot leak. Costs, stock counts, low-stock thresholds, SKUs and workspace ids never appear.
Availability is a label. It is advisory and for today: confirming an order is the only reservation (B01). It is computed by StorefrontAvailability, with one grouped query per request through Availability::availableToPromiseMany(), from stems that can be promised through the end of today in the shop’s timezone (FEFO eligibility: active, known expiry, not expired, free stems).
- A flower is
sold_outwith no promisable stems, andfew_leftat or below its low-stock threshold. - A bouquet is
sold_outwhen any component cannot be covered, andfew_leftwhen it can be made only once or twice. - The only number shown is
max_per_bouquet, capped at 99, which the bouquet builder needs.
Prices are exact and server-side. Money is {amount, currency}, with the amount as a decimal string in the currency’s own precision. Cart totals come from CartPricingService in integer minor units. Any price a client sends is ignored.
Messages follow the visitor’s language. The X-Locale header (the SDK sends it) or the locale cookie picks Ukrainian or English, as in the control panel. A storefront order keeps that language for the customer’s email. Until 6 October 2026 the API routes skipped this middleware, so validation and checkout messages were English on Ukrainian pages and orders would have been emailed in English. It was found in the checkout browser test, before any shop had switched checkout on.
Cart identity and request safety
Section titled “Cart identity and request safety”- Cart cookie. The cart is identified by a random 40-character token in the
flowery_cartcookie: httpOnly, host-only,SameSite=Lax, 30 days. Only its SHA-256 hash is stored (carts.session_id = "sfapi:" + hash). - No CSRF token needed. A POST, and any write that carries a body, must be
application/json; anything else gets 415. A cross-site form cannot send JSON, and a cross-origin script would need a CORS preflight that this API never grants.- A DELETE with no body (removing a cart line) has no content type to check, and needs none: forms can only GET or POST, and other methods from another origin need the preflight.
- Found 6 October 2026: the cart’s “Видалити” answered 415, because the SDK sends that DELETE without a body and the rule then covered every write. The tests had used
deleteJson(), which always adds the header; a test now sends the request as the SDK does.
- Server-side checks. Adding a bouquet checks that the stems can cover the quantity already in the cart plus the new one. A custom bouquet is checked stem by stem. Packaging and products must belong to the shop. A line can only be changed or removed through the visitor’s own cart.
Caching and limits
Section titled “Caching and limits”- Caching. Catalogue answers carry
Cache-Control: public, max-age=30, stale-while-revalidate=120and anETag. Cart, checkout and status-link answers carryno-store, private; the status link also sendsX-Robots-Tag: noindex, nofollowandReferrer-Policy: no-referrer. - Rate limits. Per client IP: 120 requests a minute, of which at most 30 can be cart writes, 30 checkout quotes and 5 order requests. Over the limit, the API answers 429. The Astro runtime calls the API from the server; when it presents the shared
STOREFRONT_RUNTIME_KEY(X-Storefront-Runtime), the visitor address it forwards (X-Storefront-Client-Ip) is limited instead of its own. Without the key, the forwarded header is ignored (App\Support\Storefront\StorefrontRuntime). - Astro. When Astro calls the API server-side (phase C), it must pass the visitor’s address through the trusted proxy header. Otherwise every visitor shares one limit.
Occasion tags
Section titled “Occasion tags”App\Enums\Occasion is the only list of occasions. Orders record one occasion, and catalogue bouquets carry any number of them. Staff set them with chips on the bouquet form (bouquets.occasions, jsonb). The API exposes and filters only the product occasions, which is every key except other.
tests/Feature/StorefrontApi/StorefrontApiTest.php:- Catalogue: labels, every filter and the sort orders, 404 for other shops, custom and inactive bouquets, no private keys anywhere in a response, and a fixed query budget for the bouquet list.
- Cart: the cookie, its stored hash, exact totals, sold-out and over-quantity refusals, foreign packaging, the custom bouquet stem check, ownership of lines, the JSON-only rule and the rate limit.
tests/Feature/StorefrontApi/CheckoutApiTest.php: closed checkout, options, the G1 quote, every fulfilment refusal, one order per cart, changed and expired quotes, forged fingerprints and client amounts, the open-request limit, emails and their failure, pickups, and the status link’s scope and expiry.tests/Feature/StorefrontApi/ContractTest.php: every API route is in the specification and every documented path is a route; every endpoint’s real response matches its schema, with no undocumented fields.