Stitchwork Docs

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…CredentialStart 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.

SchemeCredentialUsed 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":"ada@example.com","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.

Building an integration? You probably want a key instead

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.

Both keys can appear together

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

CodeMeaning hereWhat to do
200OK.—
201Created.—
204Accepted, no body. Event ingest and CORS preflights.—
302Redirect. Page routes only — the JSON API never redirects.If you get this from an API path, you're hitting a page route.
400Malformed request. Mostly Stripe signature failures and bad ids.Fix the request.
401Not authenticated: a missing, revoked or bad pk/sk key.Check the key and the header prefix (Public vs Bearer).
402Workspace locked. Trial expired or account suspended.Get in touch. See access.
403Authenticated but not permitted — usually owner-only, or a channel you don't have access to.Use an owner account, or grant a channel role.
404Not found, or not in your workspace. The two are deliberately indistinguishable.Check the id.
409Conflict 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.
410Gone: an expired or already-used invite, reset link or join code.Issue a fresh one.
413Import file over 20 MB.Split the file.
422Validation failed.Read the message — it names the field.
429Event ingest rate cap exceeded.Back off and retry.
500Unhandled error.Retry; if it persists, it's a bug.
502An upstream we call failed — Stripe, or a fetched import URL.Retry.
503Storage unavailable, or a feature isn't configured (Stripe keys missing, mail unset).Retry; check configuration if it persists.
402 is not 403

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

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.

EndpointFilters
/quotesstatus, channel_id, customer_id, q (quote number, customer name or email)
/ordersstatus, channel_id, customer_id, q
/productstype, status, brand, q (name or variant SKU)
/customersq (name, email or company)
/channelstype, ownership, status, country, q
API list endpoints are unpaginated

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.

EndpointMethodsPreflight
/track/eventPOSTYes
/track/joinPOSTYes
/public/recommendationsGETYes
/public/categories/interestGETYes
/public/profileGETYes
/quotes/public/lookupPOSTYes

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:

OperationKey
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 updateThe 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 importSame, derived from (external_source, order_number) when absent.
Quote → order conversionexternal_id = "quote:{id}".
Product importProduct SKU, then variant SKU.

Webhooks

Outbound events are documented separately — see Webhooks for the catalogue, the envelope, signature verification and the retry schedule.

Next