API reference
Endpoint reference
Every endpoint in the v1 API. Read the overview first for the authentication schemes, the response envelope and what each status code means here.
Session needs a signed-in cookie ·
pk_live takes Authorization: Public pk_live_… ·
sk_live takes Authorization: Bearer sk_live_… ·
Public needs no credential ·
Owner workspace owner only ·
Owner / admin owner or workspace admin.
Unless marked otherwise, every Session endpoint
also passes through the access gate and returns 402 for a locked
workspace, and 409 if the user has no workspace yet.
Authentication
Create an account and sign in. Body: name, email, password (min 8). Returns 201 with {user, next:"/onboarding"} and sets the session cookie. 409 if the email exists, 422 on validation.
Body: email, password. Returns {user, next:"/"}. 401 on bad credentials — the message does not distinguish unknown email from wrong password.
Destroys the session. Always 200.
The signed-in user. 401 when there is no session — or when the session points at a user that no longer exists, in which case the session is also cleared.
Body: email. Always returns the same 200 whether or not the account exists, so it can't be used to probe for users. Tokens are hashed at rest and expire in 60 minutes.
Body: token, password (min 8). Signs the user in on success. 410 if the link has expired or been used.
Your account
Body: name, email (both required). 409 if the email belongs to someone else.
Body: current_password, new_password (min 8). 403 if the current password is wrong. Rotates the session id on success.
Workspace
Create a workspace. Body: name (2–80 characters). Starts the 7-day trial and creates a default Website channel. If the user already has a workspace it is returned unchanged with 200 rather than erroring. Not access-gated.
The current workspace, or {"organisation": null} if there isn't one. Not access-gated, so a locked workspace can still be read.
Body: name (2–80). The slug is deliberately left alone, so your tracking tag URL doesn't break.
Team
The roster. Any member may read it. Returns {members, can_manage, me, owner_user_id} so the UI knows what to render.
Body: role — admin or member.
Remove someone from the workspace. 422 if you target yourself.
Outstanding invitations.
Body: email, org_role (admin|member), optional channel_id + channel_role (admin|manager|associate).
Response status is invited, added or channel_added — see Inviting people. 409 if they're already a member and no channel was named.
Mints a fresh token, invalidating the old link, and re-sends. Returns invite_url.
Revoke a pending invitation.
Body: token, plus name and password if the email has no account yet. Grants the membership and any channel role the invite carried, then signs them in. 410 if expired or used.
Dashboard
Query: range = 7d | 30d (default) | 90d; keys = comma-separated KPI keys. Unknown keys are ignored; an empty list falls back to revenue,orders,quotes_sent,new_customers.
Each KPI returns {key, label, unit, hint, value, prev, delta_pct}. prev is the same window length immediately before, so delta_pct is period-over-period. It is null when there is no prior baseline to compare against.
| Key | Unit | Measures |
|---|---|---|
revenue | money | Sum of paid + fulfilled order totals. |
orders | int | Paid + fulfilled orders placed in the window. |
aov | money | Revenue ÷ order count. |
quotes_sent | int | Quotes created in the window that reached sent or beyond. |
quotes_accepted | int | Quotes that reached accepted or paid. |
quote_conversion | pct | Accepted-or-paid ÷ sent-or-beyond. |
new_customers | int | Customers created in the window. |
product_views | int | view_product events. |
add_to_cart | int | add_to_cart events. |
store_visits | int | qr_scan events. |
Currency is inferred from your most common order currency, defaulting to GBP.
The KPI catalogue and the default selection. No workspace lookup, so it works before onboarding completes.
Channels
Query: type, ownership, status, country, q.
Body: name (1–120, required), type and ownership (required, see below), status, url, address_line_1, address_line_2, city, region, country (2 letters), postal_code, timezone (default UTC), currency (default GBP), attributes.
type: web, store, marketplace, kiosk, pop_up, wholesale. ownership: owned, franchise, authorised, wholesale, marketplace. status: active, paused, closed.
Requires read access to this channel — owner, workspace admin, or any channel role.
Requires edit access — owner, workspace admin, or channel admin/manager.
Sets status to closed rather than deleting, so quotes and orders keep their reference.
Channel staff
The roster for one channel. Needs channel read access.
Body: email, role (admin|manager|associate, default associate). Needs owner, workspace admin, or channel admin. An email with no account is sent a workspace invitation carrying the channel grant.
Removes the channel grant. The workspace membership is untouched.
Channel QR codes
Each row includes scan_url — the decorated URL to encode into the QR image.
Body: name (1–120), kind (product|channel_entry|custom), plus product_id for product or target_url for custom. Needs channel edit access.
The target resolves at creation: a product QR takes the product's web_url and a channel_entry QR takes the channel's URL. Either being unset is a 422 that tells you where to go and set it. Max 2048 characters.
Rename or remove. Needs channel edit access.
Products
Query: type, status, brand, q (matches name or any variant SKU). Each row carries variant_count.
Returns {product, options, variants}.
Body: name (1–200, required), sku, description, type (simple|variant|configurable, default simple), status (draft|active|discontinued, default draft), brand, category, tags, base_price, base_compare_price, currency, web_url, external_source, external_id, attributes, created_at.
A simple product gets its single variant created automatically. SKUs are max 64 characters of letters, digits, dot, hyphen, underscore or slash.
Partial update — omitted fields keep their value. Includes created_at, so you can correct the date the newness signal decays from.
Sets status to discontinued. Returns {"discontinued": id}. There is no hard product delete.
Builds the cartesian product of the product's options. Returns {created, discontinued, total, variants}. Combinations that no longer exist are discontinued, not deleted. 422 if the product has no options.
Body: featured (boolean). Applies to ranking on the next read — no recompute needed.
CSV import. Three ingestion paths: multipart/form-data with file=; a JSON body with url or csv; or a raw text/csv body. Max 20 MB (413) and 50,000 rows.
Columns, aliases and the summary shape are in Importing a catalogue.
Product options
Options in position order.
Body: name (1–40 lowercase letters, digits or underscores), display_name, position, values (at least one). Option names are unique per product.
Rename, reorder or change the value list.
The response includes a warning that variants may need regenerating — deleting an axis does not itself reconcile the variant set.
Variants
Every variant of one product.
One variant.
Look up a variant by SKU. URL-encode the SKU if it contains a slash.
Body: sku, name, price, currency, status.
Customers
Query: q (name, email or company). Ordered by name.
One customer.
Body: name and email (required), phone, company, notes, attributes. Upserts on email — an existing email updates that customer and emits customer.updated.
Partial update. A blank name or email keeps the existing value rather than clearing it.
Query: types (comma-separated event types), limit (1–200, default 50). Spans every visitor linked to this customer, with matched products joined inline.
Query: cascade=1. Without it, a customer with quotes or orders returns 409 and a body naming the exact counts. With it, everything goes in one transaction.
The linked visitor is detached, not destroyed — their browsing survives as anonymous and can be re-identified later.
Quotes
Query: status, channel_id, customer_id, q. Newest first, with customer, channel and author names joined in.
Returns {quote, lines, activities}.
Body: channel_id and customer_id (both required), currency, discount_type (amount|percent), discount_value, tax_rate, tax_inclusive, notes_internal, notes_customer, valid_until (default +30 days), line_items, visitor_token.
Line items take description, quantity (min 1), unit_price, sku, product_variant_id. Totals are computed server-side — see Totals and tax.
Draft only — 422 otherwise. Recalculates totals.
Moves to sent and emails the customer their link. 422 if the customer has no email. Mail failure is logged, not fatal — the status change still happens.
The soft path: the quote stops being actionable but stays on the customer's history and in your totals.
Hard delete — lines, timeline and join codes go with it, and totals move. An order converted from the quote survives with quote_id set null.
Body: body (required), visible_to_customer (default false). Visible notes appear on the public quote page.
Returns {url, token} — the public quote URL, for rendering as a QR or copying by hand.
Query: types. The 50 most recent events for this quote's customer, with matched products joined inline.
Quote line items
Draft only. Appended at the end. Linking product_variant_id also bumps that customer's affinity for the product.
Draft only. Body: description, sku, quantity, unit_price.
Draft only.
Public quote endpoints
The customer's view: {quote, lines, activities}, with internal notes, author details and Stripe ids stripped. Only customer-visible timeline entries are included.
Only from sent; 409 otherwise.
Optional body: reason. Only from sent.
Creates a Stripe PaymentIntent as a direct charge on the merchant's Connect account, with the platform application fee. Returns {client_secret, account_id}. 409 if the quote isn't payable, 503 if Connect isn't enabled or Stripe isn't configured, 502 if Stripe errors.
Mints a single-use, 5-minute join code and returns {available, code, bridge_url, merchant}. Returns {"available": false, "reason": "tracking_not_installed"} with 200 when no join URL is configured.
CORS-enabled. Body: number (or quote_number) and email. Any miss returns an identical generic 404, with matched timing, so quote numbers can't be enumerated. OPTIONS preflight supported.
Orders
Query: q, status, customer_id, channel_id.
Returns {order, items}.
Hard delete. The sale stops counting towards revenue and ranking. A quote it was converted from is untouched.
Only from accepted or paid (409 otherwise). Idempotent on external_id = "quote:{id}".
Shopify-style CSV, one row per line item. Same three ingestion paths as the product import. Customers are upserted by email; a ranking recompute is triggered on success. Columns in Importing from CSV.
Server-to-server order creation. Body in Creating orders from your backend. Upserts on (external_source, external_id). Not access-gated — a locked workspace still records sales.
Tracking
The tracking tag for one workspace, with its public key and consent configuration baked in. Cache-Control: public, max-age=300. Returns a JS comment and 404 if the slug is unknown or keys haven't been generated.
Body: visitor_token and url (required), type (default page_view), name, referrer, title, props. Returns 204. CORS-enabled with OPTIONS preflight.
Requires the Authorization header — the ?key= fallback is not accepted here. Rate limited per workspace per minute (429). Unknown types are coerced to custom.
Body: visitor_token, join_code. Links the visitor to the quote's customer. Returns {linked: true} or a reason — see the QR handoff.
Tracking settings
Generates a fresh pair and returns {public_key, secret_key}. The only time the secret is readable. Rotating invalidates the previous secret immediately.
Body: tracking_join_url (a full http(s) URL) or tracking_host (a bare host, in which case /__stitchwork/join is assumed). An empty value clears both.
Body: mode (auto|require|off), honour_gpc (default true).
Body: url — a template with {placeholder} tokens, max 1024 characters, http(s) only. Empty clears it. Tokens are listed in Handing off to your own checkout.
Recommendations & ranking
Query: customer_id, limit (default 20), category, ids, exclude_quote_id. Returns {products, diagnostics}. Naming a customer restricts results to products they have engaged with, and returns nothing rather than falling back to global bestsellers.
Query: visitor_token, limit, category, ids, external_ids, skus (the last two capped at 200 entries). Accepts ?key= as well as the header. CORS-enabled.
Returns {products, personalised} with internal scores stripped. Cached public, max-age=300 when anonymous and private, max-age=60 when personalised. Any internal failure degrades to an empty list rather than a 500.
Query: customer_id, visitor_token, limit (default 10). Read-only signal; does not affect product ranking.
Query: visitor_token, limit. Rows carry {category, score, views, carts, order_qty, quote_qty}. Same caching rules as public recommendations. CORS-enabled.
Deep-merges a partial config patch and returns the merged result. Keys and defaults in Tuning. Weights are clamped non-negative; the 0–1 dials are clamped; unknown keys are dropped.
Rebuilds the materialised global signals. Returns {product_count, computed_at}.
Query: limit (10–200, default 50), category_balance (0–100, to preview without saving). Returns {inputs, weights, windows, category_balance, top_products, unmatched_skus}. Readable by any member.
Body: conversion_window_days (1–365, default 90), model — time_decay (default), linear, position_based, last_touch, first_touch.
External ingest
Body: channel_id (required, must belong to the workspace), customer object with at least email (required), line_items (at least one, required), plus visitor_token, currency, discount_type, discount_value, tax_rate, tax_inclusive, notes_internal, notes_customer, valid_until.
Returns {quote, customer, public_url}. The quote is created as a draft tagged created_via: external. Not access-gated.
See Orders above.
Webhooks
Subscriptions, newest first. Signing secrets are omitted.
Body: url (required, http(s)), event_types (array or comma-separated string, default ["*"]), description. The response is the only place signing_secret appears.
One subscription, without its secret.
Body: url, event_types, active, description. Not the secret — that goes through rotation.
Issues a new whsec_ secret and returns it once. Verification on your end breaks until you deploy the new value.
Removes the subscription and its delivery history.
The 50 most recent deliveries for one subscription.
One delivery, including the attempt count, last status code, last error and the first 8 KB of your response body.
Requeue a delivery, including a dead-lettered one.
Payments
Body: country (2 letters, default GB). Creates a Stripe Express account if there isn't one, then returns a Stripe-hosted onboarding link: {url, expires_at, account_id}. 503 if Stripe isn't configured on the server, 502 if Stripe errors. Not access-gated.
Returns {account_id, charges_enabled, payouts_enabled, country}.
Stripe's own webhook receiver, verified by signature. Handles account.updated (Connect capability changes), payment_intent.succeeded (marks the quote paid) and payment_intent.payment_failed (logs a note on the quote's timeline).
Nothing here writes workspace access state, by design.
Ops
Returns 200 whenever the app process is up, with {status, db, time}. Database reachability is reported as a non-fatal sub-status so a flaky database doesn't pull instances out of the load balancer.
Applies pending SQL migrations, tracking what has run. Idempotent. Auth via X-Migrate-Token or ?token=, compared in constant time; 403 when MIGRATE_TOKEN is unset, which disables the endpoint. Returns {applied, already_in_db, available}.
Push a workspace's trial end out by days (1–365, default 30). Extends from the later of now and the current end, so an expired trial gets a fresh window. Auth via X-Admin-Key or ?key=; 403 when ADMIN_API_KEY is unset.
Example resource
A minimal CRUD resource that ships with the framework scaffolding. It is not workspace-scoped and has no authentication — useful as a reference implementation or a connectivity check, not for real data.
List, create, fetch, update and delete items with a title and a body.