Stitchwork Docs

Endpoint reference

Every endpoint you can call from your own systems. Read the overview first for the authentication schemes, the response envelope and what each status code means here.

Where to look

This page covers two different jobs, and knowing which one you are doing saves reading the other half.

The integration surface

What your storefront, your backend and your other systems call. Workspace keys, no sign-in, no expiry: tracking & identity · recommendations · nudges · public quote pages · external quotes & orders · webhooks.

The workspace API

Everything the Stitchwork app itself runs on — catalogue, customers, quotes, orders, team, channels, reporting. Marked Session, and open to you for anything you'd rather script than click. You never have to touch it to integrate.

Reading the badges

pk_live takes Authorization: Public pk_live_… · sk_live takes Authorization: Bearer sk_live_… · Public needs no credential.

What is not listed here: the endpoints Stitchwork's own screens call to draw themselves. Those authenticate with the browser session of a signed-in user, so there is no credential you could hold and nothing you could call them with. Everything the app does with them, you do through the surfaces on this site — the MCP connector for reading and briefing, the sk_live ingest endpoints below for writing, and webhooks for hearing about it afterwards.

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.

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.

post/api/v1/invites/acceptPublic

Accept a team invitation. 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.

Quotes

The customer's own half of a quote: the page they open from the link you send, and the lookup behind a "find my quote" form on your site. Everything the sales team does to a quote happens on Stitchwork's screens, or through the /api/v1/quotes/external ingest endpoint below.

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 your own Stripe account, with the Stitchwork application fee on top. 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

Starts the in-store handoff for this quote. Used by the app; the sequence behind it is covered in your implementation pack rather than here. Answers {"available": false} with 200 when no join URL is configured for the workspace, so the app knows not to offer it.

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

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.

patchput/api/v1/orders/externalsk_live

Partial update of an order already booked, found by your id: external_id in the body or the query string, optionally with external_source to disambiguate. Fields present are written, fields absent are left alone. Body in Updating an order from your backend.

May carry status, order_number, currency, subtotal, discount_amount, tax_amount, shipping_amount, total, placed_at, payment_method (free text, null clears), paid_at (null clears), channel_id, customer_id, customer (by email, looked up not created), quote_id (null unlinks), new_external_id / new_external_source (change your own id — a bare external_id selects, it never writes), metadata (merged) and line_items (replaces every line, and re-prices unless you sent the money). An unknown field name, an unreadable status or a create-only field is 422; nothing to change is 422.

Not an upsert: an unknown id is 404. An id matching two sources is 409 with a sources list; a taken order_number is 409. Fires order.updated. PUT is a synonym, not a replacement. Not access-gated.

patchput/api/v1/orders/external/{external_id}sk_live

The same call with the id in the path. Only fits an id that is a single path segment — a Magento gid://magento/Order/9655 never will be, since a web server won't pass a bare or encoded slash through. Use the body form for those. The path id wins over the body, which wins over the query string.

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

Completes the in-store handoff: links the browser to the customer, and with it every event that browser ever fired. Called by the join route on your own domain — see the in-store handoff.

post/api/v1/identifypk_live

Body: email (required), visitor_token, name, phone, source. Upserts the customer by email and attaches the visitor's prior trail to them. CORS-enabled with OPTIONS preflight. Rate limited per workspace per minute (429).

Returns 204 with an empty body, always. The key reaches this endpoint from the merchant's page source, so nothing about the customer — not a name, not even whether the address was already on file — comes back. Use the secret door below when you need the record. See identifying a visitor.

post/api/v1/customers/identifysk_live

Same body, server-to-server, and answers in full: {customer, created, linked, link_reason}. 201 for a new customer, 200 for an existing one. link_reason is no_visitor_token, unknown_visitor or visitor_belongs_to_another_customer — a visitor already tied to someone else is never silently reassigned.

post/api/v1/contactpk_live

Your own contact form, posting into your workspace. Body: email and message (both required), plus subject, name, phone, visitor_token, source, page_url. Add a hidden website field to your form and leave it empty — anything that fills it is treated as a bot and gets a success it did not earn.

One submission does three things: the sender is matched to a contact by email (or created), their browser's trail is stitched onto that contact exactly as /identify would, and the message is stored against them and emailed to your team. The message lands on the customer's activity timeline under Messages, so the reply can start from what they were reading rather than from nothing.

source names which of your forms this was (trade_enquiry, brochure) and page_url the page it was sent from; both show on the timeline entry, and page_url falls back to the Referer if you leave it out. Rate limited per workspace and per IP per minute (429). CORS-enabled with OPTIONS preflight.

Returns 201 {"received": true}, and nothing else. Same rule as /identify: the key ships in your page source, so nothing about the contact comes back — not a name, not whether they were already on file. A plain HTML form that posts without JavaScript gets a 303 back to the page instead; pass return to say where, and it is honoured only for your workspace's own tracking host. A form cannot set headers, so for that path send the key as a hidden key field (or ?key= on the action URL) — the same fallback public recommendations and profile take.

Recommendations & ranking

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

get/api/v1/public/profilepk_live

Query: visitor_token, window_days (1–365, default 90), limit (1–50, default 10). Accepts ?key= as well as the header. CORS-enabled.

Returns {personalised, identified, window_days, computed_at, recency, intent, categories, brands, products, price_affinity, transaction_value, acquisition, searches}. Every section is always present — empty is [] or null, never absent.

An unknown or unresolvable visitor_token returns the anonymous payload rather than a 404, so the endpoint cannot be used to confirm whether a token is live. identified is a boolean; no name, email or customer id is ever returned. Same caching rules as public recommendations, and the same fail-closed behaviour — if anything goes wrong our end, the visitor gets the anonymous payload rather than an error in your page. Rate limited per workspace per minute (600 by default; ask us if your traffic needs more).

Nudges

The tracking tag calls both of these for you. They are listed for headless storefronts and native apps, where there is no page for our script to run in. See Nudges for what they are for.

get/api/v1/public/nudgepk_live

Query: visitor_token (required), path, seconds_since_action, and this visit's tally — page_views, product_views, products, carts, searches. CORS-enabled with OPTIONS preflight.

Returns {nudge} — {id, message, cta_type, cta_label, cta_url, confirm_text} — or {nudge: null}. No match, a holdout, an unknown token and an absent token all return the same empty answer, so the endpoint reveals neither whether a token is live nor which arm a visitor is in.

A GET that writes: a match assigns the experiment arm before anything renders. Answers are no-store, private and must never be cached — the arm and the frequency cap both live server-side. Rate limited per workspace per minute (429). Any internal failure degrades to {nudge: null} rather than a 500 in a live storefront.

post/api/v1/public/nudge/responsepk_live

Body: visitor_token, nudge_id, kind (click, email, phone or dismiss), and value for the two that carry one. 422 if any of those are missing or unrecognised.

Returns 204 with an empty body, always — same rule as /identify: the key ships in your page source, so nothing about the customer comes back. An email identifies the visitor; a phone opens a follow-up where the browser is already stitched to a customer. Answering twice is one answer.

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.

patchput/api/v1/orders/externalsk_live

The after-checkout half of the same integration — payment cleared, parcel shipped, half of it returned. See Orders above.

Payments

post/api/v1/billing/webhookStripe

Stripe's webhook receiver, verified by signature against either of the server's two signing secrets — the platform's own events (checkouts, the subscription) and events from shops' connected accounts. Point a Stripe endpoint listening to your account here.

Nothing here writes workspace access state, by design.

post/api/v1/billing/connect/webhookStripe

The endpoint for events in shops' connected accounts, verified with the connect signing secret only. Handles payment_intent.succeeded (records a deposit, the balance or the whole amount on the quote), payment_intent.payment_failed (a note on the quote's timeline), charge.refunded (hands Stitchwork's fee on that payment back to the shop), account.updated (whether the account can take payments), account.application.deauthorized (the shop disconnected Stitchwork in Stripe) and terminal.reader.action_succeeded / action_failed (a card reader at the counter).

An event is believed only from the Stripe account the workspace it names connected.

get/api/v1/billing/connect/statusSession · owner

The workspace's Stripe account: account_id, kind (standard_oauth, standard_created or express), charges_enabled, payouts_enabled, and the plan's fee and fee_cap.

post/api/v1/billing/connect/oauthSession · owner

Returns {url} — Stripe's screen for connecting the Stripe account the shop already has. Stripe sends the owner back to /settings/payments/stripe/callback.

post/api/v1/billing/connect/onboardSession · owner

Creates a new Stripe account for the shop when it has none, and returns {url, expires_at, account_id} — a Stripe-hosted link to finish setting it up.

post/api/v1/billing/connect/refreshSession · owner

Asks Stripe now whether the account can take payments, and returns the same shape as status.

post/api/v1/billing/connect/disconnectSession · owner

Lets go of the shop's own Stripe account. An account Stitchwork set up (Express) answers 409 — get in touch to move it.

Service status

get/healthzPublic

Returns 200 whenever Stitchwork is up, with {status, db, time}. Safe to poll from your own monitoring if you want the dependency on your board.