Skip to content

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.

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

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_out with no promisable stems, and few_left at or below its low-stock threshold.
  • A bouquet is sold_out when any component cannot be covered, and few_left when 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 cookie. The cart is identified by a random 40-character token in the flowery_cart cookie: 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. Catalogue answers carry Cache-Control: public, max-age=30, stale-while-revalidate=120 and an ETag. Cart, checkout and status-link answers carry no-store, private; the status link also sends X-Robots-Tag: noindex, nofollow and Referrer-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.

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.