HTTP API reference
The Storefront API that storefront.js and the web components call, for clients in other
languages: a server-rendered site, a mobile app, a script. Version 2026-12.
It's REST and JSON: every resource is a JSON object with camelCase fields, and money is a decimal string (see Shared shapes).
Base URL
https://api.organizationdepot.com/api/storefront-api/2026-12
Every path below is relative to it. A version this server doesn't speak is 404 NOT_FOUND.
Authentication
Send your token, public (odpk_…) or preview (odpv_…), in a header on every request:
curl https://api.organizationdepot.com/api/storefront-api/2026-12/shop \
-H "X-OD-Storefront-Token: odpk_YOUR_PUBLIC_TOKEN"
- The token names the storefront, so no shop id or domain goes with it.
- Never put the token in a query string: those end up in logs. It's only read from the header.
- A missing, unknown or revoked token is
401 TOKEN_INVALID. A revoked token fails within 60 seconds of revoking it. - A storefront that isn't published is
404 NOT_FOUNDon every route, for both kinds of token. - A preview token makes preview carts: they can be created while the sale is closed, and pay only in test mode (Stripe's test cards, no order).
- No cookies are used or set. The token is the only identity.
Requests and responses
- Send bodies as JSON with
Content-Type: application/json. - Responses are JSON, and carry
Cache-Control: no-store: don't cache them in a shared cache, which doesn't key on the token header. - Optional fields are left out rather than sent as
null. - The checks run in this order: the version, the token (
401), the rate limit (429), the storefront being published (404), then the route.
CORS
The Storefront API can be called from a page on any origin. A token, not CORS, is the gate.
| Header | Value |
|---|---|
Access-Control-Allow-Origin |
* |
Access-Control-Allow-Methods |
GET, POST, PUT, DELETE, OPTIONS |
Access-Control-Allow-Headers |
Content-Type, X-OD-Storefront-Token |
Access-Control-Expose-Headers |
Retry-After |
Access-Control-Max-Age |
86400 (preflights are cached a day) |
Credentials are never allowed: don't send requests with credentials: "include", or the browser
refuses the response. A request header other than the two above fails the preflight.
Rate limits
Requests are limited per token and client address, so one visitor can't use up a shop's
allowance. The current limits are 120 reads (GET) and 60 writes a minute. Someone browsing
and shopping stays well under them; a script hammering the API doesn't.
Over the limit, the answer is 429 RATE_LIMITED with a Retry-After header, in seconds. Wait that
long before trying again. A server calling on behalf of many buyers shares one address: cache what
you can.
Errors
Every refusal has the same body:
{
"code": "CUSTOMIZATION_INVALID",
"message": "Engraving is at most 30 characters.",
"field": "engraving"
}
code is stable, for branching; message explains; field, when present, names the request field
or answer key it's about.
A message is a sentence for the buyer for CUSTOMIZATION_INVALID, PURCHASE_LIMIT,
OUT_OF_STOCK, SALE_CLOSED, CART_LOCKED and CART_CONVERTED, and for INVALID on a field the
buyer fills in: code, quantity, lines[n].quantity, email or name. Any other INVALID
(first, after, lines, lines[n].variantId, or none) is for the developer; see
Which messages to show a buyer.
| Status | code |
When |
|---|---|---|
400 |
INVALID |
The request can't succeed as sent: a bad quantity, email or name, an unusable discount code (field says which), or a bad first or cursor |
400 |
CUSTOMIZATION_INVALID |
An answer doesn't fit the product's form; field is its key |
400 |
PURCHASE_LIMIT |
More than the product's purchaseLimit in one order |
401 |
TOKEN_INVALID |
The token is missing, unknown or revoked |
404 |
NOT_FOUND |
No such product, variant, line or cart on this storefront; the storefront isn't published; an unknown version |
409 |
CART_LOCKED |
The cart is mid-checkout: release it, then retry |
409 |
CART_CONVERTED |
The cart was paid for, or has expired (ABANDONED): start a new one |
409 |
OUT_OF_STOCK |
Not enough left for that quantity |
409 |
SALE_CLOSED |
Starting a cart while the sale isn't open; message says why. A preview token gets it only on an archived shop |
429 |
RATE_LIMITED |
Too many requests; see Retry-After |
Shared shapes
// Money: a decimal string in major units ("38.00" is $38).
interface Money {
amount: string; // "38.00"
currencyCode: string; // "USD"
}
// A photo, with absolute URLs to each size on the API's host (/media/v2/…).
interface Image {
url: string; // the original size
altText: string; // the product's title when nothing better exists
sizes: { small: string; medium: string; original: string };
}
interface LabelledValue {
label: string;
value: string;
}
Form is the platform form JSON: a tree whose FIELD nodes ask something. A node may have
kind (FORM, GROUP, ARRAY, FIELD), field (the answer key; else id), label,
helpText (some forms use description instead), hidden, type, options
([{ label, value }], for SELECT), validators and controls (its children).
- Field types. A shop form's fields are
TEXT,NUMBER,SELECTorCHECKBOX. Organization Depot refusesDATEfields in shop forms. - Rules.
REQUIREDon any field;MAX_LENGTH(max),MIN_LENGTH(min) andPATTERN(pattern,message) onTEXT;MINIMUM(min) andMAXIMUM(max) onNUMBER. Only the server checks aPATTERN: an answer that doesn't match isCUSTOMIZATION_INVALID, with the rule'smessageor "'Label' does not match the required format."EMAILandFULL_NAMErules are refused in shop forms. - Layout keys. Forms also carry
span(columns per screen size, such as{ "xs": 12 }),structural(on the root form) andmultiple(on aSELECT, alwaysfalsehere). They're for Organization Depot's own form renderer. Skip them, and anything else you don't recognize.
{
"kind": "FORM",
"structural": true,
"controls": [
{
"kind": "FIELD",
"field": "engraving",
"label": "Engraving",
"helpText": "Up to 30 characters",
"type": "TEXT",
"hidden": false,
"span": { "xs": 12 },
"validators": [{ "type": "REQUIRED" }, { "type": "MAX_LENGTH", "max": 30 }]
}
]
}
Answers are keyed by a field's key: a string for TEXT and SELECT, a number for NUMBER, a
boolean for CHECKBOX.
GET /shop
The storefront the token names.
interface Shop {
handle: string; // the shop's slug, e.g. "papas-woodworks"
title: string;
currencyCode: string;
organization?: { name: string; logoUrl?: string; verified: boolean };
branding?: { [token: string]: string }; // "color.brand": "#7c4a1e", …
sale: {
opensAt?: string; // ISO 8601
closesAt?: string;
timezone: string; // "America/Chicago"
availableForSale: boolean; // whether buyers can order now
reason?: "NOT_OPEN_YET" | "PAUSED" | "ENDED"; // only when availableForSale is false
};
websiteUrl?: string; // "Your website", where checkout's back links go
mode: "HOSTED" | "CHECKOUT_ONLY";
checkoutDomain: string; // the host checkoutUrl points at, e.g. "shop.papaswoodworks.com"
url: string; // the hosted shop page, e.g. "https://shop.papaswoodworks.com"
checkoutQuestions?: Form; // questions asked once per order
preview: boolean; // true with a preview token: its carts work whatever the sale says
}
sale reports the real sale whichever token you send. preview is true for a
preview token, whose carts can be created while the sale is closed
and pay only in test mode, so a client enables Add to cart when
sale.availableForSale || preview.
GET /products
A page of products, in the shop's display order.
| Query | Default | What |
|---|---|---|
first |
20 |
Page size, 1 to 100; outside that is 400 INVALID |
after |
The previous page's endCursor (opaque) |
|
tag |
Only products with this tag, ignoring case (see GET /tags) |
interface ProductList {
products: Product[];
pageInfo: { hasNextPage: boolean; endCursor?: string }; // endCursor is absent on an empty page
}
Only active products appear on this API.
GET /products/{handle}
One product. handle is its slug, or its id when it has no slug; both are accepted. Unknown, or not
for sale, is 404 NOT_FOUND.
interface Product {
id: string;
handle: string;
title: string;
description?: string;
tags: string[];
availableForSale: boolean; // some variant is in stock (not about the sale)
images: Image[];
featuredImage?: Image;
options: {
id: string;
name: string; // "Wood"
values: { id: string; value: string; group?: string; image?: Image }[];
}[];
variants: {
id: string;
title: string; // "Walnut / Small"
price: Money;
availableForSale: boolean; // in stock: untracked, or some left (not about the sale)
quantityAvailable?: number; // only when stock is tracked
selectedOptions: { name: string; value: string }[];
image?: Image; // the photo of its photo option's value
}[];
priceRange: { minVariantPrice: Money; maxVariantPrice: Money };
customizationForm?: Form; // per-item personalization
properties: LabelledValue[]; // details
purchaseLimit?: number; // the most one order may hold
}
GET /tags
Every tag on the products this API lists, with how many carry each.
interface TagList {
tags: { name: string; productCount: number }[];
}
productCountis the number of productsGET /products?tag={name}returns: active products, sold-out ones included. It doesn't change while the sale is closed, or with a preview token.- A tag matches ignoring case and surrounding spaces, like the
tagfilter.nameis spelled as on the first product, in display order, that carries it. - Tags come in the order they first appear in the shop's display order. A tag only on a draft or withdrawn product isn't listed.
- The list isn't paged.
The cart
A cart belongs to the token's storefront and to its kind of token: another storefront's cart is
404 NOT_FOUND, and so is a preview cart asked for with a public token, or a real cart asked for
with a preview token. Every cart route returns the whole Cart.
interface Cart {
id: string; // a key to the buyer's cart: don't log it or put it in URLs
status: "OPEN" | "LOCKED" | "CONVERTED" | "ABANDONED";
lines: CartLine[];
totalQuantity: number;
answers: { [key: string]: string | number | boolean }; // checkout questions
answersSummary: LabelledValue[];
contact: { email?: string; name?: string };
discountCodes: { code: string }[];
cost: { subtotalAmount: Money }; // tax, fees and the total come at checkout
checkoutUrl: string; // {shop url}/c/{cartId}
preview: boolean; // made with a preview token: pays only in test mode
}
interface CartLine {
id: string;
quantity: number;
merchandise: {
id: string; // the variant id
title: string;
image?: Image; // the variant's photo, else the product's first
selectedOptions: { name: string; value: string }[];
product: { id: string; handle: string; title: string };
};
customizations: { [key: string]: string | number | boolean };
customizationSummary: LabelledValue[];
cost: { amountPerQuantity: Money; subtotalAmount: Money };
}
interface CartLineInput {
variantId: string;
quantity: number; // 1 or more
customizations?: { [key: string]: string | number | boolean }; // checked against the product's form
}
| Route | Body | Returns |
|---|---|---|
POST /carts |
{ lines?, contact?, answers? } |
201 and the new cart |
GET /carts/{cartId} |
The cart, in any status | |
POST /carts/{cartId}/lines |
{ lines: CartLineInput[] } |
The cart, with the lines added |
PUT /carts/{cartId}/lines/{lineId} |
{ quantity } |
The cart; 0 removes the line |
DELETE /carts/{cartId}/lines/{lineId} |
The cart, without the line | |
PUT /carts/{cartId}/answers |
{ answers } |
The cart, its answers replaced |
PUT /carts/{cartId}/contact |
{ email?, name? } |
The cart, its contact replaced |
PUT /carts/{cartId}/discount-codes |
{ code } |
The cart, with the code added |
DELETE /carts/{cartId}/discount-codes/{code} |
The cart, without the code | |
POST /carts/{cartId}/release |
The cart: LOCKED back to OPEN; otherwise unchanged |
POST /carts
Creates a cart, optionally with lines, a contact and answers in one go. The body may be empty.
With a public token, this is 409 SALE_CLOSED while shop.sale.availableForSale is false, with
a message for the buyer that says why ("This shop isn't taking orders right now."). With a
preview token it succeeds whether the sale is open or not (only an archived shop still refuses)
and makes a preview cart: preview: true, which pays only in test mode at the hosted checkout.
GET /carts/{cartId}
Returns the cart in any status. CONVERTED (paid) or ABANDONED (expired) means: forget this id
and start a new cart. So does 404.
POST /carts/{cartId}/lines
Adds up to 50 lines, all or none. Each needs a variantId of a product for sale on this storefront
(else 404 NOT_FOUND) and a quantity of 1 or more (else 400 INVALID, with field like
lines[0].quantity). Customizations are checked against the product's customizationForm
(CUSTOMIZATION_INVALID). A line with the same variant and the same customizations as one in the
cart adds to its quantity. Asking for more than is in stock is 409 OUT_OF_STOCK.
PUT /carts/{cartId}/lines/{lineId}
Sets a line's quantity, 0 or more; 0 removes it. Raising it past the stock left is
409 OUT_OF_STOCK.
PUT /carts/{cartId}/answers
Replaces all the answers to the shop's checkout questions. A required answer may still be missing; the hosted checkout asks for it.
PUT /carts/{cartId}/contact
Replaces the contact: a field you leave out is cleared. email must be an email address and
name at most 200 characters (400 INVALID with field).
PUT /carts/{cartId}/discount-codes
Adds one code. A code that can't be used (unknown, expired, used up, or for nothing in the cart) is
400 INVALID with field: "code" and a message for the buyer.
DELETE /carts/{cartId}/discount-codes/{code}
Removes a code; nothing happens if it wasn't on the cart.
POST /carts/{cartId}/release
While the buyer is on the checkout page, the cart is LOCKED and edits are 409 CART_LOCKED. When
they come back to your site and change the cart, release it and retry the edit once:
PUT /carts/{id}/lines/{lineId} → 409 CART_LOCKED
POST /carts/{id}/release → 200, status OPEN
PUT /carts/{id}/lines/{lineId} → 200
The checkout prices the cart again when they return to it. On a cart that isn't locked, release does nothing.
Checkout
checkoutUrl is the shop's url followed by /c/{cartId}: https://shop.papaswoodworks.com/c/…
on a connected domain, or https://papaswoodworks.storefront.organizationdepot.com/papas-woodworks/c/…
on the platform address, where each shop has its own path. Send the buyer's browser there (a
link, or a redirect from your server); there's no API call for checkout. The page stores the cart, removes
the id from the address bar, and shows the hosted checkout. See
The checkout handoff.
Versioning
A released API version only gains fields: new optional fields can appear in 2026-12, so
ignore fields you don't know. Anything that would break a client ships as a new
version, and each version is supported for at least 12 months. See
Versioning.