Organization DepotDevelopers v1.3.0API 2026-12
All pages

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.js with a plain <script src>. With type="module" it runs as a module, and the OrgDepot global is never defined. For a module, use storefront.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 },
]);
  • quantity is 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 (with field), 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) or INVALID.

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_CART when there's no cart.
  • Only in a browser page. Elsewhere it throws UNSUPPORTED_ENVIRONMENT before any request: on a server, redirect the buyer to cart.checkoutUrl yourself.
  • 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 no localStorage, 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 to cart.checkoutUrl.
  • Rate limits are per token and client address, and a server is one address for all its buyers: cache what you render.