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 the catalog, inventory, or an order changes (Stripe-style HMAC)
- • Customer-facing order status lookup without an account system
fims_* keys).
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
What follows covers the endpoints you'll reach for most, with the context a guide can give and a schema can't. The complete, always-current list — every parameter, every response field — is generated from the API itself in the Storefront API explorer. If the two ever disagree, the explorer is right.
GET /storefront/products
List products listed on this channel. Pagination + filtering.
Query params:
search(aliasq) — free-text on product name + description and on variant identifiers: SKU, MPN, UPC, GTIN, and the channel SKU. A part-number search resolves to the product without a separate identifier index.categoryId(aliascategory) — category idbrand— brand idinStockOnly,priceMin,priceMaxinclude— comma-separated expansions.include=variantsattaches each product's fullvariants[](identifiers, price, inventory, options, images) — the same shape as the detail response. Lets you build cards or pull the whole catalog without a per-product detail call. Opt-in, so the default list stays lean.sort—name|-name|newest|featured(featured → display order → name) |manual(display order) |price/-price(best-effort)page(default 1),limit(default 50, max 200)
Each product (summary) carries id, slug, name, description, brand, category, priceFrom, compareAtPriceFrom (only when on sale), currency, featured, displayOrder, inStock, availableQuantity, variantCount, thumbnail, customFields, and createdAt/updatedAt. Add ?include=variants for the full variants[] too.
GET /storefront/products/:idOrSlug
Full detail: everything in the summary plus options, an image gallery, and variants[]. Resolves by product id or slug — use the slug for stable, SEO-friendly URLs. Each variant carries sku (the channel SKU, falling back to the Foundry SKU), mpn, upc, gtin, price, compareAtPrice (the "was"/MSRP, for strike-through sale display), map, weight, inStock, availableQuantity, optionValues, images, and customFields. To get these variant rows across a listing (e.g. a build-time catalog sync) without a request per product, call /storefront/products?include=variants and page through.
Summary vs. detail — which fields come from where
The paginated list returns the summary shape; ?include=variants adds the variant rows to it, and the detail endpoint adds options + gallery on top.
| Field | List (summary) | List + ?include=variants | Detail |
|---|---|---|---|
id, slug, name, description, brand, category | ✓ | ✓ | ✓ |
priceFrom, compareAtPriceFrom, currency | ✓ | ✓ | ✓ |
inStock, availableQuantity, variantCount | ✓ | ✓ | ✓ |
thumbnail, featured, displayOrder, customFields | ✓ | ✓ | ✓ |
variants[] — per-variant sku/mpn/upc/gtin, price, availableQuantity, optionValues, images | — | ✓ | ✓ |
options (product-level), image gallery | — | — | ✓ |
Search indexes: product name + description, and each listed variant's sku, mpn, upc, gtin, and channel SKU. Availability: availableQuantity is reservation-aware (on-hand minus reserved). At the product (summary) level it sums tracked variants only — a made-to-order product with untracked variants reports availableQuantity: 0 but inStock: true, so use inStock for the yes/no and availableQuantity for "N left" messaging.
GET /storefront/variants/:id
Resolve a single variant by id — price, compareAtPrice, availability, option values, images, and minimal product context. Use this to re-validate cart line items (keyed by variantId) without re-fetching whole products.
GET /storefront/categories
Walkable tree (children nested under parents; ancestor categories are included so breadcrumbs resolve). Each node has id, slug, name, parentId, and children.
GET /storefront/categories/:idOrSlug/products
Products in a category, by category id or slug. Same response shape and query params as /products.
GET /storefront/brands
Flat list.
GET /storefront/search?q=
Text search across product name + description and variant identifiers (SKU, MPN, UPC, GTIN, channel SKU). Same response shape and params as /storefront/products — including include=variants.
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. Set a webhook URL on your channel and Foundry POSTs signed events to it.
There is no self-serve control for this yet — ask support to set the URL on your channel, or call
PUT /channels/:id with {"webhookUrl": "https://…"}
using an admin API key. The signing secret is generated by Foundry; retrieve it with
POST /channels/:id/rotate-webhook-secret. You do not supply your own secret,
and Foundry never sends it — deliveries are authenticated by the HMAC signature described below.
Register the exact URL you want called, including any trailing slash. Redirects are followed, but a
301/302 downgrades the request to a bodyless GET —
only 307/308 preserve the POST body.
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)catalog.changed— fires when products, variants, categories, or channel listings/prices change. Detected within ~30s and coalesced, so a bulk import produces one event rather than thousands
catalog.changed is a cache-invalidation signal. ids is best-effort —
capped per entity kind, with truncated: true when more rows changed than we list. When
truncated is set, revalidate wholesale rather than walking ids. Hard-deleted records
cannot be detected this way, so treat the event as "something moved, re-read" rather than a precise changelog.
{
"event": "catalog.changed",
"deliveredAt": "2026-07-19T11:03:00.227Z",
"data": {
"changedSince": "2026-07-19T11:02:30.000Z",
"changedAt": "2026-07-19T11:03:00.000Z",
"entities": [
{ "entity": "variant", "ids": ["uuid", "uuid"], "truncated": false },
{ "entity": "listing", "ids": ["uuid"], "truncated": false }
]
}
}
// entity is one of: product | variant | category | listing
// "listing" = this channel's ChannelSku rows (price / listed state) 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
POST /channels/:id/rotate-webhook-secret returns the new secret. Rotation is
immediate: Foundry signs every delivery — including retries of in-flight ones — with the new secret right away. The
previous value is retained for 24h so your receiver can accept either while you roll it out, but you must
implement that dual-check yourself; Foundry does not send two signatures.
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.