Organization DepotDevelopers v1.3.0API 2026-12
All pages

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_FOUND on 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, SELECT or CHECKBOX. Organization Depot refuses DATE fields in shop forms.
  • Rules. REQUIRED on any field; MAX_LENGTH (max), MIN_LENGTH (min) and PATTERN (pattern, message) on TEXT; MINIMUM (min) and MAXIMUM (max) on NUMBER. Only the server checks a PATTERN: an answer that doesn't match is CUSTOMIZATION_INVALID, with the rule's message or "'Label' does not match the required format." EMAIL and FULL_NAME rules are refused in shop forms.
  • Layout keys. Forms also carry span (columns per screen size, such as { "xs": 12 }), structural (on the root form) and multiple (on a SELECT, always false here). 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 }[];
}
  • productCount is the number of products GET /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 tag filter. name is 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.