Storefront API
Build your own storefront — Next.js, Astro, mobile, anything — and use Foundry as the inventory and order source of truth. Foundry serves catalog data, validates inventory, and ingests finalized orders. Your storefront owns cart, checkout, customers, and payments.
What you get
- • Channel-scoped catalog reads (only products you've listed on this channel)
- • Real-time inventory checks with reservation-aware availability
- • Server-side total recompute on order create (defends against stale-price attacks)
- • Idempotent order creation via
Idempotency-Keyheader - • Signed outbound webhooks when orders or inventory change (Stripe-style HMAC)
- • Customer-facing order status lookup without an account system
Quickstart
- In the Foundry admin: Sales Channels → New, pick platform Headless, give it a name.
- On the channel's settings page, add your storefront's origin(s) to Allowed Origins (e.g.,
https://shop.example.com). - Settings → API Keys → Create. Pick Storefront, pick the channel, save the
fims_sf_*key somewhere safe. - Drop the key in your storefront's
.envand hit the API.
curl https://api.foundryims.com/api/v1/storefront/products \
-H "Authorization: Bearer fims_sf_xxx" Authentication
Every request needs Authorization: Bearer fims_sf_xxx. The key authenticates against a single Headless sales channel — Foundry rejects admin (fims_*) keys on the storefront surface.
- 401 — missing/malformed header, or invalid/revoked key
- 403 — key isn't bound to a Headless channel, or origin not in allow-list
CORS
Browser requests are accepted only from origins in the channel's Allowed Origins list. If the list is empty Foundry serves a wildcard (with a server log warning) — don't ship production storefronts that way. Add specific origins from the channel settings page.
Catalog endpoints
GET /storefront/products
List products listed on this channel. Pagination + filtering.
Query params: category, brand, q, inStockOnly, priceMin, priceMax, sort (name|-name|newest|price|-price), page (default 1), limit (default 50, max 200)
GET /storefront/products/:id
Full detail: variants, options, images, attributes.
GET /storefront/categories
Hierarchical tree (children nested under parents).
GET /storefront/categories/:id/products
Products in a category. Same response shape as /products.
GET /storefront/brands
Flat list.
GET /storefront/search?q=
Text search across product name + description.
ETag and Cache-Control: public, max-age=60, stale-while-revalidate=300. Send If-None-Match on follow-up requests to get a 304. Put a CDN in front if you can.
Inventory check
POST /storefront/inventory/check
Real-time availability for a batch of variants — useful right before checkout. Reservation-aware (subtracts pending order reservations).
{
"items": [
{ "variantId": "uuid-1", "qty": 2 },
{ "variantId": "uuid-2", "qty": 1 }
]
} Response shape:
{
"items": [
{ "variantId": "uuid-1", "available": true, "onHand": 14 },
{ "variantId": "uuid-2", "available": false, "onHand": 0 }
]
} Orders
POST /storefront/orders
Submit a finalized order. Foundry recomputes totals from current ChannelSku.price values and rejects with a 422 priceMismatch payload if the client total drifts more than $0.01.
{
"customer": { "email": "[email protected]", "name": "Jane Buyer", "phone": "+1..." },
"shippingAddress": { "line1": "...", "city": "...", "state": "...", "postal": "...", "country": "US" },
"billingAddress": { "..." },
"lineItems": [
{ "variantId": "uuid-1", "quantity": 2, "unitPrice": 19.99 }
],
"shipping": { "method": "USPS Priority", "cost": 8.50 },
"tax": { "amount": 3.20 },
"discount": { "code": "WELCOME10", "amount": 4.00 },
"payment": { "provider": "stripe", "chargeId": "ch_xxx", "amount": 47.68 },
"totals": { "subtotal": 39.98, "shipping": 8.50, "tax": 3.20, "discount": 4.00, "total": 47.68 }
} Response on success:
{
"orderId": "uuid",
"orderNumber": "SF-A1B2C3D4",
"status": "PROCESSING",
"lookupToken": "abc123...xyz"
} Idempotency
Send Idempotency-Key: <unique-per-attempt>. Replaying the same key with the same body returns the original response (no double order). Replaying with a different body returns 409. Keys are retained for 24h.
GET /storefront/orders/:lookupToken
Customer-facing order status. The lookupToken returned at order creation IS the auth — no API key required. Returns sanitized order detail (status, line items, totals, shipping address, tracking). Email is partially redacted. Give this URL to your customer in the order-confirmation email.
POST /storefront/orders/:id/regenerate-lookup-token
Authed. Mints a new lookupToken and invalidates the old one. Useful when a customer says they lost the link.
Webhooks
Skip polling. Configure a Webhook URL on the channel's settings page; Foundry POSTs signed events to it.
Events
order.status_changed— fires when an order moves through statuses (paid → fulfilled → shipped → delivered → refunded)inventory.changed— fires per variant when available quantity shifts (throttled to one event per variant per 10s)
Payload shapes for price.changed, product.updated, and product.delisted are reserved but not yet emitted upstream.
Request shape
POST <your webhook URL>
Headers:
Content-Type: application/json
X-Foundry-Event: order.status_changed
X-Foundry-Timestamp: 1747526400
X-Foundry-Delivery-Id: dlv_abc123
X-Foundry-Signature: t=1747526400,v1=<hex hmac sha256>
Body:
{ "event": "order.status_changed", "deliveredAt": "...", "data": { ... } } Verifying signatures
Compute HMAC-SHA256 over {timestamp}.{rawBody} with your webhook secret, compare to the v1 value in the signature header. Stripe-style.
import crypto from "crypto";
function verify(req, secret) {
const sigHeader = req.headers["x-foundry-signature"];
const ts = req.headers["x-foundry-timestamp"];
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${req.rawBody}`)
.digest("hex");
const received = sigHeader.match(/v1=([0-9a-f]+)/)?.[1];
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
);
} Retries
Non-2xx (and 429/5xx) responses are retried with exponential backoff: 5s → 30s → 5min → 1h, then terminal. 4xx (except 429) is terminal immediately — your endpoint rejected the shape, no point retrying. Always dedup on X-Foundry-Delivery-Id; the same delivery may fire twice across instance restarts.
Rotating the secret
From the channel settings page (or POST /channels/:id/rotate-webhook-secret). After rotation the previous secret is accepted for 24h so you have a deploy window to roll receivers.
Rate limits
- Catalog reads — 100 req/min per channel
POST /orders— 30 req/min per channelPOST /inventory/check— 100 req/min per channel- Order lookup (unauth) — 60 req/min per token
429 responses include Retry-After in seconds.
Errors
- 401 — missing/invalid/revoked API key, or admin key on storefront surface
- 403 — key not bound to a Headless channel, channel inactive, or origin blocked by CORS
- 404 — product/order not found (or not visible on this channel)
- 409 — idempotency key reused with a different body
- 422 —
priceMismatch(server total differs from client) orunknownVariants(line item references a variant not listed on this channel) - 429 — rate limited, retry after the indicated delay
Need a working example?
We're shipping a minimal Next.js starter at github.com/Epic-Design-Labs/foundry-storefront-starter that demonstrates list → check → checkout end-to-end. Book a demo and we'll walk you through it.