API overview
One versioned JSON API, five authentication schemes, one response envelope. Read this page once and the endpoint reference becomes a lookup table.
What you'll actually build
Most integrations are three calls and a script tag. Before the reference, it's worth knowing which parts of the API you are likely to touch — and in what order.
| You want to… | Credential | Start at |
|---|---|---|
| Track browsing on your storefront | pk_live | Paste the tag — see Web tracking. It calls the API for you. |
| Personalise a grid, a widget or a banner | pk_live | /public/recommendations, /public/categories/interest |
| Join a signed-in shopper to their Stitchwork record | sk_live | /track/join — the handoff that makes everything else personal |
| Push orders from your own checkout | sk_live | /orders/external, idempotent on your own id |
| Let a shopper reserve online and finish in store | sk_live | /quotes/external |
| Feed your OMS, ERP or workflow tool | you verify whsec_… |
Webhooks — signed, retried, no polling |
| Point an AI agent at your own customer data | mcp_live | AI agents (MCP) |
| Automate something you'd otherwise click | Session | The workspace API — the same one the app runs on |
Nothing in the first seven rows requires the eighth. The tag, the storefront endpoints, the server-side handoff, webhooks and MCP are a complete integration on their own — the workspace API is there when you want to script something, not a step you have to take.
Base URL and versioning
https://app.stitchwork.io/api/v1/…
The version is in the path and in the response body, and a future v2 will
live alongside v1 rather than replacing it — existing clients keep working.
That is a commitment, not an intention: an integration you ship this quarter is not
something you will be asked to rewrite next year.
Two paths sit outside the versioned API because they are served to browsers rather than
to clients: /tag/{slug}.js (the tracking script) and /q/{token}
(the public quote page). There is also /healthz, if you want Stitchwork on
your own status board.
Authentication
Which scheme you use depends on who is calling, not on which resource you want. That is deliberate: the credential you hand to browser JavaScript cannot read anything sensitive, and the one that can never has to leave your server.
| Scheme | Credential | Used by |
|---|---|---|
| Public key | pk_live_… |
Browser JavaScript on your storefront. Event ingest and the read-only storefront endpoints. |
| Secret key | sk_live_… |
Your own backend. The join endpoint and external quote / order creation. |
| Token in URL | Quote public_token |
The customer's own phone. Public quote pages — the token is the authentication. |
| MCP token | mcp_live_… |
A headless AI agent on POST /mcp. Per-seat, read-only, created in Settings → MCP. See AI agents. |
| OAuth access token | mcp_at_… |
Claude or any MCP client a person connected by URL. Issued by the built-in OAuth 2.1 flow, hour-long, refreshed silently, revocable instantly. |
| Session | Cookie | A signed-in person — your team in the app, or a script you write that acts as one of them. Covers the workspace itself: catalogue, customers, quotes, orders, settings. |
Public key
Authorization: Public pk_live_xxxxxxxxxxxx
Or, where a custom header is inconvenient (a plain fetch, an
<img> beacon), ?key=pk_live_… on the query string —
accepted by the public read endpoints and the quote lookup, but not by
event ingest, which requires the header.
The key is workspace-scoped and effectively write-only: nothing sensitive is readable with it, which is why embedding it in page source is fine.
Secret key
Authorization: Bearer sk_live_xxxxxxxxxxxx
Server-side only. Stored hashed, so it cannot be read back — a lost key is replaced by rotating, which issues a new pair and invalidates the old secret everywhere.
Session
This is how your team uses Stitchwork every day — they sign in and the app carries the
cookie. You only need it directly if you want to script something you would otherwise
click. Sign in, keep the cookie, send it on subsequent requests. The cookie is
HttpOnly, SameSite=Lax, 14 days, and Secure over
HTTPS.
curl -X POST https://app.stitchwork.io/api/v1/auth/login \
-H "Content-Type: application/json" -c cookies.txt \
-d '{"email":"[email protected]","password":"…"}'
curl https://app.stitchwork.io/api/v1/quotes -b cookies.txt
The signed-in user resolves to exactly one workspace, and every session-authenticated request is scoped to it. There is no workspace parameter to pass or to get wrong — and a script written this way inherits its author's permissions exactly, so it can never reach a store or a channel they couldn't open themselves.
A session belongs to a person: it expires, it follows them out of the business, and it
is the wrong shape for a server that has to run at 3am. For anything your own systems
call — tracking, personalisation, the customer handoff, order sync — use
pk_live or sk_live above. They're workspace credentials, they
don't expire, and you can rotate them without anyone signing in.
Response envelope
Success responses wrap their payload:
{
"version": "v1",
"data": {
"quote": { "id": 7, "quote_number": "Q-2026-000007", "…": "…" }
}
}
data is keyed by resource name — quote, quotes,
customer, products, subscription and so on — never
a bare array. A response that carries several related things carries several keys:
{"quote": …, "lines": […], "activities": […]}.
Errors are flat:
{ "error": "Only draft quotes can be edited." }
Error messages are written to be shown to a human, and the app renders them directly. A
few errors also carry a data block with the structured detail you need to
act — the customer-delete cascade warning is the main one.
409 from DELETE /customers/{id} returns
error and data, because the caller needs the
dependent counts to decide whether to retry with ?cascade=1. Parse for
error first: if it is present, the request failed regardless of what else
is in the body.
Status codes
| Code | Meaning here | What to do |
|---|---|---|
200 | OK. | — |
201 | Created. | — |
204 | Accepted, no body. Event ingest and CORS preflights. | — |
302 | Redirect. Page routes only — the JSON API never redirects. | If you get this from an API path, you're hitting a page route. |
400 | Malformed request. Mostly Stripe signature failures and bad ids. | Fix the request. |
401 | Not authenticated: a missing, revoked or bad pk/sk key. | Check the key and the header prefix (Public vs Bearer). |
402 | Workspace locked. Trial expired or account suspended. | Get in touch. See access. |
403 | Authenticated but not permitted — usually owner-only, or a channel you don't have access to. | Use an owner account, or grant a channel role. |
404 | Not found, or not in your workspace. The two are deliberately indistinguishable. | Check the id. |
409 | Conflict with current state: no workspace yet, email already taken, quote in the wrong status, delete needs cascade, visitor already linked elsewhere. | Read the message — it says what conflicts. |
410 | Gone: an expired or already-used invite, reset link or join code. | Issue a fresh one. |
413 | Import file over 20 MB. | Split the file. |
422 | Validation failed. | Read the message — it names the field. |
429 | Event ingest rate cap exceeded. | Back off and retry. |
500 | Unhandled error. | Retry; if it persists, it's a bug. |
502 | An upstream we call failed — Stripe, or a fetched import URL. | Retry. |
503 | Storage unavailable, or a feature isn't configured (Stripe keys missing, mail unset). | Retry; check configuration if it persists. |
402 means the workspace is locked — every user in it gets the same
answer. 403 means you aren't allowed — a colleague with more
rights would succeed. Handling them identically produces a confusing support ticket.
Request format
Send JSON with Content-Type: application/json. Form-encoded bodies are also
accepted for simple endpoints — the two are read through the same accessor, with
form fields taking precedence. Imports additionally accept
multipart/form-data and raw text/csv.
A malformed JSON body is treated as an empty one rather than erroring, so a required
field will surface as a 422 naming that field rather than a parse error.
Verbs
PUT and PATCH are the same handler everywhere they both exist,
and both behave as a partial update: fields you omit keep their current value. There is
no full-replace semantics to trip over.
Value types
- Money is a decimal string —
"2850.00"— never a float. Numbers are accepted on input and normalised to two decimal places. - Booleans accept
true,1,"1","true","t","yes"and"on". Anything else is false. - Dates accept anything parseable; ISO 8601 is safest. Timestamps come back in ISO 8601 with an offset.
- Currency is a 3-letter ISO code, upper-cased on input.
- Country is a 2-letter ISO code.
Filtering and pagination
List endpoints take filters as query parameters, and q is a free-text search
wherever it appears. Filters are exact-match unless the reference says otherwise; an
empty value is ignored rather than matching empty.
| Endpoint | Filters |
|---|---|
/quotes | status, channel_id, customer_id, q (quote number, customer name or email) |
/orders | status, channel_id, customer_id, q |
/products | type, status, brand, q (name or variant SKU) |
/customers | q (name, email or company) |
/channels | type, ownership, status, country, q |
They return the full filtered set. Pagination exists on the HTML list pages (50 per page), not on the JSON API. Filter narrowly on large catalogues rather than pulling everything and slicing client-side.
Two endpoints do cap: /customers/{id}/events takes an explicit
limit (1–200, default 50), and /quotes/{id}/browsing is fixed
at the 50 most recent.
CORS
Only the endpoints designed to be called from a browser send CORS headers. Everything else is same-origin — the app's own JavaScript — and will fail a cross-origin call.
| Endpoint | Methods | Preflight |
|---|---|---|
/track/event | POST | Yes |
/track/join | POST | Yes |
/public/recommendations | GET | Yes |
/public/categories/interest | GET | Yes |
/public/profile | GET | Yes |
/quotes/public/lookup | POST | Yes |
All send Access-Control-Allow-Origin: *. That is safe because none of them
accept cookies as authentication — they take a workspace-scoped key instead, so a
third-party page cannot ride a signed-in user's session.
Rate limits
One rate limit exists: event ingest, capped per workspace per minute
(1200 by default; ask us if your traffic needs more). Over the cap returns
429 with {"error":"Too many events."}.
Nothing else is rate limited today. Two size limits do apply: imports are capped at 20 MB and 50,000 rows, and the product importer is allowed a ten-minute execution window because being cut off mid-file is worse than being slow.
Idempotency
There is no idempotency-key header. Instead, the operations that need to be safe to retry are keyed on natural identifiers:
| Operation | Key |
|---|---|
| Create a customer | (organization_id, email) — an existing email updates instead of failing. |
| External order | (organization_id, external_source, external_id). Send your own external_id or a retry creates a duplicate. |
| External order update | The same key finds the order to patch. Nothing is created if it misses — an unknown id is 404, and re-sending a patch that already applied changes nothing. See updating an order. |
| Order import | Same, derived from (external_source, order_number) when absent. |
| Quote → order conversion | external_id = "quote:{id}". |
| Product import | Product SKU, then variant SKU. |
Webhooks
Outbound events are documented separately — see Webhooks for the catalogue, the envelope, signature verification and the retry schedule.