Stitchwork Docs
stitchwork.io

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.

SchemeCredentialUsed 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:

HeaderEnv varEndpoint
X-Migrate-TokenMIGRATE_TOKEN/api/v1/migrate
X-Admin-KeyADMIN_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.

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: no session, or a bad pk/sk key.Sign in, or 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. Also a disabled ops endpoint.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
/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 — 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:

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

Next