Shop · Developers
Build on Shop
Run a headless storefront, sync orders to your back office, or extend Shop from your own plugin. Everything here is the same surface the Shop admin and checkout use.
What you can build
Headless storefronts
Back-office integrations
Plugins that extend Shop
justflows.shop/contracts.AI agents
shop_* MCP tools run the same staff routes as the admin, with the same checks.Base URL
https://your-site.example/api/shop/v1The older /ext/justflows.shop/v1 address is an alias. The API exists only on sites where Shop is active. Paths in this reference are relative to the base URL.
Authentication
Shop uses the host’s site-bound session, not API keys. Every operation is one of three kinds:
| Access | Who | How |
|---|---|---|
public | Anyone | Storefront, cart, and checkout. No sign-in. Writes send the CSRF token. |
customer | A signed-in customer | The jf_session cookie. Shop checks that the order, link, or subscription belongs to the account. |
staff | Staff with a Shop capability | The jf_session cookie and one of the capabilities listed on each operation, or shop:settings. |
Every write keeps the host’s CSRF policy. A guest first calls GET /api/auth/csrf, keeps the jf_csrf cookie, and sends its value as x-csrf-token. Cart and checkout calls send the cart’s token as x-jf-cart; keep it private. Responses are Cache-Control: private, no-store.
A headless checkout
const SHOP = "https://your-site.example/api/shop/v1";
// 1. A guest asks for the jf_csrf cookie and sends its value back on every write.
await fetch("https://your-site.example/api/auth/csrf", { credentials: "include" });
const csrf = document.cookie.match(/(?:^|; )jf_csrf=([^;]+)/)?.[1];
async function shop(path, { method = "GET", body, cart } = {}) {
const headers = { "Content-Type": "application/json" };
if (method !== "GET") headers["x-csrf-token"] = csrf;
if (method !== "GET" && path !== "checkout/quote") headers["Idempotency-Key"] = crypto.randomUUID();
if (cart) headers["x-jf-cart"] = cart;
const res = await fetch(`${SHOP}/${path}`, { method, headers, credentials: "include", body: body && JSON.stringify(body) });
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error.message), json.error);
return json.data;
}
// 2. Add a product. The first add starts a cart and returns its token: keep it private.
const { token: cart } = await shop("cart/items", { method: "POST", body: { contentId: productId, quantity: 1 } });
// 3. Price the order without placing it.
const order = { email: "sam@example.com", shipping: address, billingSame: true, methodId, paymentId, acceptTerms: true };
const quote = await shop("checkout/quote", { method: "POST", body: order, cart });
// 4. Place it and send the shopper to the payment page.
const placed = await shop("checkout", { method: "POST", body: order, cart });
window.location.href = placed.redirectUrl;Responses and errors
A successful operation wraps its result in data. Failures use one envelope, and field names the input to fix when it is known: a body field such as email, or an address field such as shipping.postal.
{ "data": { "ok": true, "orderNumber": "JF-261027-K4P9QZ" } }{
"error": {
"code": "address_invalid",
"message": "Shipping address needs a street, city, postal code, and country.",
"field": "shipping.postal"
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request, idempotency_key_required, client_identity_required, invalid_limit, invalid_cursor | The request is incomplete or invalid. |
| 401 | unauthorized | Sign in first. |
| 403 | forbidden | The session lacks the capability. |
| 404 | not_found | No such resource on this site, or not yours. |
| 409 | setup_required | Shop setup is not finished, or Shop needs reactivating after an upgrade. |
| 409 | idempotency_conflict | The key was already used with a different request. |
| 409 | idempotency_in_progress | An earlier request with this key has no confirmed outcome. |
| 413 | request_too_large | The request is over 1 MB. |
| 429 | rate_limited | Too many requests: 60 a minute for the v1 operations. |
| 502 | label_unknown | The carrier did not say whether it made the label. |
Idempotency keys
Every v1 mutation needs an Idempotency-Key of 8–128 printable ASCII characters. checkout/quote is read-only and needs none. Generate a fresh UUID for each logical operation and keep it while you retry.
- Repeating the same request replays the saved response with
Idempotency-Replayed: true. - The same key with a different body, query, parameters, or locale returns
409 idempotency_conflict. - A request interrupted after Shop claimed the key returns
409 idempotency_in_progress. Inspect the order or payment before using a new key, so an interrupted provider call never charges twice. - Keys are scoped to the site, the session or cart, the method, and the route, and are kept until Shop data is deleted. Only a salted fingerprint of the request is stored.
Lists and pagination
GET orders, GET customers, and GET subscriptions return site-scoped pages sorted by created_at, then id. Pass limit (1–100, default 50) and the opaque cursor from the previous page, and stop when nextCursor is null. A cursor belongs to one resource, and edits can show between pages. Lists expose documented scalar fields only, never payment references or private order keys.
{ "data": [], "page": { "limit": 50, "nextCursor": null } }Money
Amounts you send are decimal strings in major units, such as "10.00", never JSON numbers. Stored totals and event amounts are integer minor units with a currency and precision: 2541 EUR at precision 2 is EUR 25.41.
OpenAPI and contracts
GET /api/shop/v1/openapi.json describes the surface in OpenAPI 3.1, and the same operations are merged into the host’s /api/v1/openapi.json through the openapi.document filter. For TypeScript, the justflows.shop/contracts export provides ShopApiError, ShopApiPage<T>, ShopEventMap, PaymentGateway, ShippingCarrier, ShopPaymentAdapter, and ShopCarrierAdapter. Type imports never load Shop’s runtime.
Versioning
The HTTP version and each event’s data.version are independent. Adding an optional field is compatible. Removing a field, changing its meaning or unit, or making a field required needs a new major version and a changelog entry, and v1 stays available while clients move.