Stitchwork Docs
stitchwork.io

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.

Reading the badges

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

post/api/v1/auth/registerPublic

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.

post/api/v1/auth/loginPublic

Body: email, password. Returns {user, next:"/"}. 401 on bad credentials — the message does not distinguish unknown email from wrong password.

post/api/v1/auth/logoutPublic

Destroys the session. Always 200.

get/api/v1/auth/meSession

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.

post/api/v1/auth/forgot-passwordPublic

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.

post/api/v1/auth/reset-passwordPublic

Body: token, password (min 8). Signs the user in on success. 410 if the link has expired or been used.

Your account

putpatch/api/v1/users/meSession

Body: name, email (both required). 409 if the email belongs to someone else.

post/api/v1/users/me/passwordSession

Body: current_password, new_password (min 8). 403 if the current password is wrong. Rotates the session id on success.

Workspace

post/api/v1/organizationsSession

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.

get/api/v1/organizations/meSession

The current workspace, or {"organisation": null} if there isn't one. Not access-gated, so a locked workspace can still be read.

putpatch/api/v1/organizations/meSessionOwner / admin

Body: name (2–80). The slug is deliberately left alone, so your tracking tag URL doesn't break.

Team

get/api/v1/team/membersSession

The roster. Any member may read it. Returns {members, can_manage, me, owner_user_id} so the UI knows what to render.

patch/api/v1/team/members/{userId}SessionOwner / admin

Body: roleadmin or member.

delete/api/v1/team/members/{userId}SessionOwner / admin

Remove someone from the workspace. 422 if you target yourself.

get/api/v1/team/invitationsSessionOwner / admin

Outstanding invitations.

post/api/v1/team/invitationsSessionOwner / admin

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.

post/api/v1/team/invitations/{id}/resendSessionOwner / admin

Mints a fresh token, invalidating the old link, and re-sends. Returns invite_url.

delete/api/v1/team/invitations/{id}SessionOwner / admin

Revoke a pending invitation.

post/api/v1/invites/acceptPublic

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

get/api/v1/dashboard/kpisSession

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.

KeyUnitMeasures
revenuemoneySum of paid + fulfilled order totals.
ordersintPaid + fulfilled orders placed in the window.
aovmoneyRevenue ÷ order count.
quotes_sentintQuotes created in the window that reached sent or beyond.
quotes_acceptedintQuotes that reached accepted or paid.
quote_conversionpctAccepted-or-paid ÷ sent-or-beyond.
new_customersintCustomers created in the window.
product_viewsintview_product events.
add_to_cartintadd_to_cart events.
store_visitsintqr_scan events.

Currency is inferred from your most common order currency, defaulting to GBP.

get/api/v1/dashboard/kpis/catalogueSession

The KPI catalogue and the default selection. No workspace lookup, so it works before onboarding completes.

Channels

get/api/v1/channelsSession

Query: type, ownership, status, country, q.

post/api/v1/channelsSessionOwner / admin

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.

get/api/v1/channels/{id}Session

Requires read access to this channel — owner, workspace admin, or any channel role.

putpatch/api/v1/channels/{id}Session

Requires edit access — owner, workspace admin, or channel admin/manager.

delete/api/v1/channels/{id}SessionOwner / admin

Sets status to closed rather than deleting, so quotes and orders keep their reference.

Channel staff

get/api/v1/channels/{id}/usersSession

The roster for one channel. Needs channel read access.

post/api/v1/channels/{id}/usersSession

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.

delete/api/v1/channels/{id}/users/{user_id}Session

Removes the channel grant. The workspace membership is untouched.

Channel QR codes

get/api/v1/channels/{id}/qr-codesSession

Each row includes scan_url — the decorated URL to encode into the QR image.

post/api/v1/channels/{id}/qr-codesSession

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.

patchdelete/api/v1/channels/{id}/qr-codes/{qr}Session

Rename or remove. Needs channel edit access.

Products

get/api/v1/productsSession

Query: type, status, brand, q (matches name or any variant SKU). Each row carries variant_count.

get/api/v1/products/{id}Session

Returns {product, options, variants}.

post/api/v1/productsSessionOwner

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.

putpatch/api/v1/products/{id}SessionOwner

Partial update — omitted fields keep their value. Includes created_at, so you can correct the date the newness signal decays from.

delete/api/v1/products/{id}SessionOwner

Sets status to discontinued. Returns {"discontinued": id}. There is no hard product delete.

post/api/v1/products/{id}/generate-variantsSessionOwner

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.

patch/api/v1/products/{id}/featuredSessionOwner / admin

Body: featured (boolean). Applies to ranking on the next read — no recompute needed.

post/api/v1/products/importSessionOwner

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

get/api/v1/products/{id}/optionsSession

Options in position order.

post/api/v1/products/{id}/optionsSessionOwner

Body: name (1–40 lowercase letters, digits or underscores), display_name, position, values (at least one). Option names are unique per product.

patch/api/v1/products/{id}/options/{option_id}SessionOwner

Rename, reorder or change the value list.

delete/api/v1/products/{id}/options/{option_id}SessionOwner

The response includes a warning that variants may need regenerating — deleting an axis does not itself reconcile the variant set.

Variants

get/api/v1/products/{id}/variantsSession

Every variant of one product.

get/api/v1/variants/{id}Session

One variant.

get/api/v1/variants/by-sku/{sku}Session

Look up a variant by SKU. URL-encode the SKU if it contains a slash.

patch/api/v1/variants/{id}SessionOwner

Body: sku, name, price, currency, status.

Customers

get/api/v1/customersSession

Query: q (name, email or company). Ordered by name.

get/api/v1/customers/{id}Session

One customer.

post/api/v1/customersSession

Body: name and email (required), phone, company, notes, attributes. Upserts on email — an existing email updates that customer and emits customer.updated.

putpatch/api/v1/customers/{id}Session

Partial update. A blank name or email keeps the existing value rather than clearing it.

get/api/v1/customers/{id}/eventsSession

Query: types (comma-separated event types), limit (1–200, default 50). Spans every visitor linked to this customer, with matched products joined inline.

delete/api/v1/customers/{id}SessionOwner

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

get/api/v1/quotesSession

Query: status, channel_id, customer_id, q. Newest first, with customer, channel and author names joined in.

get/api/v1/quotes/{id}Session

Returns {quote, lines, activities}.

post/api/v1/quotesSession

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.

putpatch/api/v1/quotes/{id}Session

Draft only422 otherwise. Recalculates totals.

post/api/v1/quotes/{id}/sendSession

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.

post/api/v1/quotes/{id}/cancelSession

The soft path: the quote stops being actionable but stays on the customer's history and in your totals.

delete/api/v1/quotes/{id}SessionOwner

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.

post/api/v1/quotes/{id}/notesSession

Body: body (required), visible_to_customer (default false). Visible notes appear on the public quote page.

get/api/v1/quotes/{id}/qrSession

Returns {url, token} — the public quote URL, for rendering as a QR or copying by hand.

get/api/v1/quotes/{id}/browsingSession

Query: types. The 50 most recent events for this quote's customer, with matched products joined inline.

Quote line items

post/api/v1/quotes/{id}/linesSession

Draft only. Appended at the end. Linking product_variant_id also bumps that customer's affinity for the product.

patch/api/v1/quotes/{id}/lines/{lineId}Session

Draft only. Body: description, sku, quantity, unit_price.

delete/api/v1/quotes/{id}/lines/{lineId}Session

Draft only.

Public quote endpoints

get/api/v1/quotes/public/{token}Token

The customer's view: {quote, lines, activities}, with internal notes, author details and Stripe ids stripped. Only customer-visible timeline entries are included.

post/api/v1/quotes/public/{token}/acceptToken

Only from sent; 409 otherwise.

post/api/v1/quotes/public/{token}/declineToken

Optional body: reason. Only from sent.

post/api/v1/quotes/public/{token}/payToken

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.

post/api/v1/quotes/public/{token}/join-codeToken

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.

post/api/v1/quotes/public/lookuppk_live

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

get/api/v1/ordersSession

Query: q, status, customer_id, channel_id.

get/api/v1/orders/{id}Session

Returns {order, items}.

delete/api/v1/orders/{id}SessionOwner

Hard delete. The sale stops counting towards revenue and ranking. A quote it was converted from is untouched.

post/api/v1/quotes/{id}/convert-to-orderSession

Only from accepted or paid (409 otherwise). Idempotent on external_id = "quote:{id}".

post/api/v1/orders/importSessionOwner

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.

post/api/v1/orders/externalsk_live

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

get/tag/{slug}.jsPublic

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.

post/api/v1/track/eventpk_live

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.

post/api/v1/track/joinsk_live

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

post/api/v1/settings/tracking/keysSessionOwner / admin

Generates a fresh pair and returns {public_key, secret_key}. The only time the secret is readable. Rotating invalidates the previous secret immediately.

patch/api/v1/settings/trackingSessionOwner / admin

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.

patch/api/v1/settings/tracking/consentSessionOwner / admin

Body: mode (auto|require|off), honour_gpc (default true).

patch/api/v1/settings/quote-complete-urlSessionOwner / admin

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

get/api/v1/recommendationsSession

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.

get/api/v1/public/recommendationspk_live

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.

get/api/v1/categories/interestSession

Query: customer_id, visitor_token, limit (default 10). Read-only signal; does not affect product ranking.

get/api/v1/public/categories/interestpk_live

Query: visitor_token, limit. Rows carry {category, score, views, carts, order_qty, quote_qty}. Same caching rules as public recommendations. CORS-enabled.

patch/api/v1/settings/rankingSessionOwner / admin

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.

post/api/v1/rankings/recomputeSessionOwner / admin

Rebuilds the materialised global signals. Returns {product_count, computed_at}.

get/api/v1/rankings/diagnosticsSession

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.

patch/api/v1/settings/attributionSessionOwner / admin

Body: conversion_window_days (1–365, default 90), modeltime_decay (default), linear, position_based, last_touch, first_touch.

External ingest

post/api/v1/quotes/externalsk_live

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.

post/api/v1/orders/externalsk_live

See Orders above.

Webhooks

get/api/v1/webhooksSession

Subscriptions, newest first. Signing secrets are omitted.

post/api/v1/webhooksSession

Body: url (required, http(s)), event_types (array or comma-separated string, default ["*"]), description. The response is the only place signing_secret appears.

get/api/v1/webhooks/{id}Session

One subscription, without its secret.

putpatch/api/v1/webhooks/{id}Session

Body: url, event_types, active, description. Not the secret — that goes through rotation.

post/api/v1/webhooks/{id}/rotate-secretSession

Issues a new whsec_ secret and returns it once. Verification on your end breaks until you deploy the new value.

delete/api/v1/webhooks/{id}SessionOwner

Removes the subscription and its delivery history.

get/api/v1/webhooks/{id}/deliveriesSession

The 50 most recent deliveries for one subscription.

get/api/v1/webhooks/deliveries/{id}Session

One delivery, including the attempt count, last status code, last error and the first 8 KB of your response body.

post/api/v1/webhooks/deliveries/{id}/resendSession

Requeue a delivery, including a dead-lettered one.

Payments

post/api/v1/billing/connect/onboardSessionOwner

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.

get/api/v1/billing/connect/statusSessionOwner

Returns {account_id, charges_enabled, payouts_enabled, country}.

post/api/v1/billing/webhookStripe

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

get/healthzPublic

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.

getpost/api/v1/migrateX-Migrate-Token

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}.

post/api/v1/admin/orgs/{id}/extend-trialX-Admin-Key

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.

getpost putpatch delete /api/v1/items · /api/v1/items/{id} None

List, create, fetch, update and delete items with a title and a body.