API reference
API overview
One versioned JSON API, four authentication schemes, one response envelope. Read this page once and the endpoint reference becomes a lookup table.
Base URL and versioning
https://app.stitchwork.io/api/v1/…
Self-hosted instances serve the same paths under whatever APP_URL is set to.
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.
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 for load balancers.
Authentication
Which scheme you use depends on who is calling, not on which resource you want.
| Scheme | Credential | Used by |
|---|---|---|
| Session | Cookie | The app itself, and anything driving it as a signed-in user. Everything under /api/v1 that isn't listed below. |
| Public key | pk_live_… |
Browser JavaScript on your storefront. Event ingest and the read-only public 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. |
Session
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.
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.
Ops credentials
Two more exist for deployment plumbing, both disabled unless the instance sets them:
| Header | Env var | Endpoint |
|---|---|---|
X-Migrate-Token | MIGRATE_TOKEN | /api/v1/migrate |
X-Admin-Key | ADMIN_API_KEY | /api/v1/admin/… |
Both also accept ?token= / ?key= and are compared in constant time.
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: no session, or a bad pk/sk key. | Sign in, or 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. Also a disabled ops endpoint. | 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 |
/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 — configurable per deployment). 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. |
| 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. |
| Migrations | Applied filenames are tracked; re-running applies only what is pending. |
Webhooks
Outbound events are documented separately — see Webhooks for the catalogue, the envelope, signature verification and the retry schedule.