JavaScript reference
Everything storefront.js 1.3.0 offers: the client, its options, every call, the event, the
helpers and the errors. It speaks Storefront API 2026-12.
Loading
| File | Kind | How |
|---|---|---|
storefront.js |
Classic script | <script src="https://developers.organizationdepot.com/storefront/v1/storefront.js"></script> |
storefront.mjs |
ES module | import { … } from "https://developers.organizationdepot.com/storefront/v1/storefront.mjs" |
The classic script defines one global, OrgDepot; the module exports the same names:
| Export | What |
|---|---|
createStorefrontClient |
Makes a client for one storefront |
StorefrontError |
The class of every error the client throws |
formatMoney |
Formats a Money for the buyer |
variantFor |
The variant for a choice of options |
customizationFields |
A form as plain fields to render inputs from |
imageUrl |
An image's URL at a size |
storefront.js is about 3.3 kB gzipped and storefront.mjs about
3.7 kB. Both run in every evergreen browser, in Node 18+ and in edge workers.
Note: load
storefront.jswith a plain<script src>. Withtype="module"it runs as a module, and theOrgDepotglobal is never defined. For a module, usestorefront.mjs.
createStorefrontClient(options)
const store = OrgDepot.createStorefrontClient({
token: "odpk_YOUR_PUBLIC_TOKEN", // required
apiUrl: "https://api.organizationdepot.com/api", // the default
apiVersion: "2026-12", // the default, and the only version so far
cartStorage: "localStorage", // the default; or "none"
fetch: undefined, // a fetch to use instead of the global one
});
| Option | Default | What |
|---|---|---|
token |
required | Your token, odpk_… or odpv_…. Throws a TypeError when it's missing or blank |
apiUrl |
https://api.organizationdepot.com/api |
The API's address. Local Organization Depot backend: http://localhost:12001/api |
apiVersion |
"2026-12" |
The Storefront API version |
cartStorage |
"localStorage" |
Where the cart id is kept. "none" keeps it in memory only, for the client's lifetime |
fetch |
the global fetch |
A fetch to use instead, for a proxy, tests or an older runtime |
Every request goes to {apiUrl}/storefront-api/{apiVersion}/… with the token in the
X-OD-Storefront-Token header, never in the URL. No cookies are sent.
Pass a public token (odpk_…) on your live site and a preview token
(odpv_…) while you develop: the client works the same with either, and the carts a preview token
makes have preview: true and pay only in test mode. With a preview token shop.preview is
true, and its carts can be started while the sale is closed. Keep the preview token in your local
settings (see Local development).
It returns a client: { shop, products, cart, on }. Make one per page; the web components share one
per token, which you can reach as document.querySelector("od-store").client.
client.shop.get()
Returns a Promise<Shop>: the storefront the token names.
const shop = await store.shop.get();
shop.title; // "Papa's Woodworks"
shop.currencyCode; // "USD"
shop.sale.availableForSale; // false while the sale is closed
shop.sale.reason; // why it's closed: "NOT_OPEN_YET", "PAUSED" or "ENDED"; absent while open
shop.preview; // true with a preview token: its carts work whatever the sale says
shop.checkoutDomain; // "shop.papaswoodworks.com"
shop.url; // "https://shop.papaswoodworks.com", the hosted shop page
shop.checkoutQuestions; // a Form, if the shop asks questions once per order
const canAdd = shop.sale.availableForSale || shop.preview; // enable Add to cart
The fields are listed under GET /shop.
client.products.list(params?)
Returns a Promise<{ products: Product[]; pageInfo: PageInfo }>: a page of products in your shop's
display order.
| Param | Default | What |
|---|---|---|
first |
20 |
Page size, 1 to 100. Outside that is INVALID |
after |
none | The previous page's pageInfo.endCursor |
tag |
none | Only products with this tag (list them with tags.list()) |
let after;
do {
const page = await store.products.list({ tag: "Cutting boards", first: 50, after });
show(page.products);
after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : undefined;
} while (after);
client.products.get(handle)
Returns a Promise<Product | null>: one product by its handle (its slug, or its id). It returns
null, rather than throwing, when there's no such product for sale, or when handle is empty.
const board = await store.products.get("walnut-end-grain-board");
board?.variants; // [{ id, title, price, availableForSale, selectedOptions, image? }, …]
A product's and a variant's availableForSale are about stock only: they stay true while the
sale is closed. Check shop.sale.availableForSale (or shop.preview) for the sale.
The product's fields are under GET /products/{handle}.
client.tags.list()
Returns a Promise<{ tags: Tag[] }>: every tag on your products, with how many carry each, in your
shop's display order. It's one request, however many products you have.
const { tags } = await store.tags.list();
// [{ name: "Cutting boards", productCount: 12 }, { name: "Gifts", productCount: 4 }]
for (const tag of tags) {
const a = document.createElement("a");
a.href = `?tag=${encodeURIComponent(tag.name)}`;
a.textContent = `${tag.name} (${tag.productCount})`;
nav.append(a);
}
productCount is how many products products.list({ tag: name }) returns, sold-out ones
included. See GET /tags.
client.cart.get()
Returns a Promise<Cart | null>: the buyer's stored cart, or null when there's none. When the
stored cart turns out to be paid for (CONVERTED), ABANDONED or unknown, it's forgotten,
cart:change fires with null, and get() returns null.
const cart = await store.cart.get();
cart?.totalQuantity; // 3
cart?.cost.subtotalAmount; // { amount: "110.00", currencyCode: "USD" }
client.cart.addLine(line) and addLines(lines)
Add to the cart, creating it when there's none. Each returns a Promise<Cart>, the updated cart.
await store.cart.addLine({
variantId: "var_walnut_s",
quantity: 1,
customizations: { engraving: "J & J" }, // answers to the product's customizationForm
});
await store.cart.addLines([
{ variantId: "var_walnut_s", quantity: 1 },
{ variantId: "var_maple_l", quantity: 2 },
]);
quantityis 1 or more. Up to 50 lines per call, and they're added all or none.- A line with the same variant and the same customizations as one in the cart adds to its quantity.
- Fails with
CUSTOMIZATION_INVALID(withfield),PURCHASE_LIMIT,OUT_OF_STOCK,SALE_CLOSED(starting a cart while the sale is closed, with a public token),NOT_FOUND(no such variant for sale) orINVALID.
client.cart.updateLine(lineId, quantity)
Sets a line's quantity; 0 removes it. Returns the updated cart. Throws NO_CART when there's no
cart. Raising a quantity past the stock left is OUT_OF_STOCK.
client.cart.removeLine(lineId)
Removes a line. Returns the updated cart. Throws NO_CART when there's no cart.
client.cart.setAnswers(answers)
Answers the shop's checkout questions: an object keyed by each
field's key. It replaces all the cart's answers. Starts a cart when there's none.
await store.cart.setAnswers({ pickup: "Saturday", gift: true });
client.cart.setContact(contact)
Sets the buyer's email and name on the cart: { email?, name? }. It replaces the contact, so a
field you leave out is cleared. An email that isn't one, or a name over 200 characters, is
INVALID with field set. Starts a cart when there's none.
client.cart.applyDiscountCode(code)
Adds a discount code to the cart. A code that can't be used (unknown, expired, used up, or for
nothing in the cart) is INVALID with field: "code" and a message to show the buyer. The cart's
discountCodes lists the codes on it. Throws NO_CART when there's no cart.
client.cart.removeDiscountCode(code)
Takes a code off the cart; nothing happens if it wasn't there. Throws NO_CART when there's no
cart.
client.cart.checkout()
Sends the buyer to the hosted checkout: it reads the cart, then navigates to its checkoutUrl with
location.assign. Returns a Promise<void> that resolves as the page starts to leave.
- Throws
NO_CARTwhen there's no cart. - Only in a browser page. Elsewhere it throws
UNSUPPORTED_ENVIRONMENTbefore any request: on a server, redirect the buyer tocart.checkoutUrlyourself. - A preview cart (
cart.preview) goes to the hosted checkout too, which shows the real totals and takes Stripe's test cards, with no order and no money.
See The checkout handoff.
client.cart.clear()
Forgets the stored cart id; the cart itself isn't touched. Fires cart:change with null. The
next addLine starts a new cart. Returns nothing.
client.on(event, listener)
Listens to an event, and returns a function that stops listening. There's one event,
"cart:change"; any other name throws a TypeError.
const stop = store.on("cart:change", (cart) => {
badge.textContent = String(cart?.totalQuantity ?? 0);
});
// later: stop();
cart:change passes the new cart after every call that changes it, and null on clear() or when
the stored cart turns out to be over. A plain cart.get() doesn't fire it, so a listener can call
get() safely. A listener that throws is reported to the console and doesn't stop the call or the
other listeners. Events are per client.
formatMoney(money, locale?)
Formats a Money as the buyer reads it, with the browser's language unless you pass locale.
OrgDepot.formatMoney({ amount: "38.00", currencyCode: "USD" }); // "$38.00"
OrgDepot.formatMoney({ amount: "38.00", currencyCode: "EUR" }, "de-DE"); // "38,00 €"
An amount or currency that can't be formatted comes back plain ("38.00 XYZ") instead of throwing.
Pass a fixed locale when a page rendered on a server must match the browser.
variantFor(product, selected)
Returns the Variant for a choice of option values, by option name, or undefined until every
option has a value, or when that combination isn't sold. Names and values match exactly as the API
spells them. A product without options has one variant, which variantFor(product, {}) returns.
const variant = OrgDepot.variantFor(board, { Wood: "Walnut", Size: "Large" });
if (!shop.sale.availableForSale && !shop.preview) showSaleClosed(shop.sale.reason);
else if (!variant) showUnavailable();
else if (!variant.availableForSale) showSoldOut(); // stock only
customizationFields(form?)
Returns a form's fields, in order, as plain descriptors to render inputs from: a product's
customizationForm or the shop's checkoutQuestions. Groups are flattened, hidden fields left out;
no form gives [].
interface FieldDescriptor {
key: string; // send the answer under this key
label: string;
type: "TEXT" | "NUMBER" | "SELECT" | "CHECKBOX";
required: boolean;
options?: { value: string; label: string }[]; // SELECT
min?: number; // NUMBER, inclusive
max?: number; // NUMBER, inclusive
maxLength?: number; // TEXT
description?: string; // help text to show under the input
}
Answer TEXT and SELECT with a string (one of the options values), NUMBER with a number and
CHECKBOX with a boolean. The server checks every answer again; see
Personalization.
It reads the rules REQUIRED, MINIMUM, MAXIMUM and MAX_LENGTH. A TEXT field can also have
MIN_LENGTH or PATTERN: the server checks those and explains a refusal
(CUSTOMIZATION_INVALID), and a descriptor doesn't carry them. A field's description is its
help text: the form's helpText, or description in forms that use that key. Shop forms have no
DATE fields; given one from another form, customizationFields reads it as TEXT.
imageUrl(image, size?)
Returns an image's URL at a size, "small", "medium" or "original" (the default), falling back
to its original URL. Returns undefined when there's no image, so it's safe on optional fields:
img.src = OrgDepot.imageUrl(variant?.image ?? product.featuredImage, "medium") ?? "";
Errors
Every failure is thrown as a StorefrontError (a subclass of Error):
| Property | What |
|---|---|
code |
Stable, for branching. See the table below |
message |
The explanation; the server's own words for codes it sends |
status |
The HTTP status, or 0 when the client raised the error itself |
field |
For CUSTOMIZATION_INVALID, the answer key; for INVALID, the request field |
retryAfter |
For RATE_LIMITED: the seconds to wait, when the server said |
cause |
For NETWORK_ERROR: the original error |
code |
status |
When |
|---|---|---|
INVALID |
400 | The request can't succeed as sent: a bad quantity, email or name, an unusable discount code, or a bad paging parameter |
CUSTOMIZATION_INVALID |
400 | An answer doesn't fit the product's form; field names it |
PURCHASE_LIMIT |
400 | More than the product's purchaseLimit |
TOKEN_INVALID |
401 | The token is missing, unknown or revoked |
NOT_FOUND |
404 | No such product, variant or line, or the storefront isn't published |
CART_LOCKED |
409 | Still locked after the automatic release and retry |
CART_CONVERTED |
409 | The cart was already paid for, or has expired |
OUT_OF_STOCK |
409 | Not enough left |
SALE_CLOSED |
409 | Starting a cart while the sale isn't open; the message says why |
RATE_LIMITED |
429 | Too many requests from this buyer; wait retryAfter seconds |
SERVER_ERROR / HTTP_ERROR |
5xx / other | A failure without a code of its own |
NETWORK_ERROR |
0 | The API couldn't be reached (offline, DNS, blocked); see cause |
INVALID_RESPONSE |
0 | The response wasn't JSON, or the cart had no usable checkoutUrl |
NO_CART |
0 | A call that needs a cart (updateLine, checkout(), …) when there's none |
UNSUPPORTED_ENVIRONMENT |
0 | checkout() outside a browser, or no fetch available |
The client retries nothing by itself except a locked cart (release, then once more); it doesn't
retry a 429. See Troubleshooting for the common ones.
Which messages to show a buyer
Show error.message as written for CUSTOMIZATION_INVALID, PURCHASE_LIMIT, OUT_OF_STOCK,
SALE_CLOSED, CART_LOCKED and CART_CONVERTED, and for INVALID when field is one the buyer
fills in:
field |
For example |
|---|---|
code |
"Enter a discount code.", "'SPRING10' has expired" |
quantity, lines[n].quantity |
"Enter a quantity of 1 or more." |
email |
"Enter an email address, like name@example.com." |
name |
"Enter a name of 200 characters or fewer." |
Any other INVALID (first, after, lines, lines[n].variantId, or no field) means the page
sent something the API doesn't take. That message is for you: log it, and show the buyer your own
words.
const BUYER_FIELD = /^(code|email|name|quantity|lines\[\d+\]\.quantity)$/;
const BUYER_CODES = [
"CUSTOMIZATION_INVALID",
"PURCHASE_LIMIT",
"OUT_OF_STOCK",
"SALE_CLOSED",
"CART_LOCKED",
"CART_CONVERTED",
];
function messageFor(error, fallback) {
if (!(error instanceof OrgDepot.StorefrontError) || !error.message) return fallback;
if (error.code === "INVALID") {
return BUYER_FIELD.test(error.field ?? "") ? error.message : fallback;
}
return BUYER_CODES.includes(error.code) ? error.message : fallback;
}
The web components and the starters follow this rule.
TypeScript
Every shape is exported as a type: Shop, Product, Tag, Variant, ProductOption, Image,
Money, Cart, CartLine, CartLineInput, Form, FieldDescriptor, StorefrontClient,
StorefrontClientOptions, StorefrontError, StorefrontErrorCode and more. The declarations ship
with each release:
https://developers.organizationdepot.com/storefront/v1/types/index.d.ts
https://developers.organizationdepot.com/storefront/1.3.0/types/index.d.ts
index.d.ts imports the others beside it (client.d.ts, types.d.ts, errors.d.ts,
helpers.d.ts, customization.d.ts, cart-id-store.d.ts). Download them into a types/ folder
and map the module to them in tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "bundler",
"paths": { "@orgdepot/storefront": ["./types/index.d.ts"] }
}
}
Then import { createStorefrontClient } from "@orgdepot/storefront", and have your bundler load
that module from developers.organizationdepot.com. The vite-ts starter
does both, with a script that downloads the types.
On a server
storefront.js runs in Node 18+ and edge workers, so a site can render its catalog on the server,
which gives search engines real HTML:
import { createStorefrontClient } from "https://developers.organizationdepot.com/storefront/v1/storefront.mjs";
// Node can't import from a URL: download storefront.mjs into your project, or use your
// framework's way of loading a remote module.
const store = createStorefrontClient({ token: process.env.STOREFRONT_TOKEN, cartStorage: "none" });
const { products } = await store.products.list({ first: 100 });
- Use
cartStorage: "none"there: there's nolocalStorage, and the client is one per request. - Keep the cart in the browser. The client can't resume a cart by its id, so a server that keeps the cart id in its own session calls the HTTP API for the cart instead.
cart.checkout()needs a browser; on a server, redirect tocart.checkoutUrl.- Rate limits are per token and client address, and a server is one address for all its buyers: cache what you render.