Help
Troubleshooting
The failure modes that actually happen, what causes them, and how to confirm the fix rather than hoping.
Reading a status code
Start here — the code usually tells you which half of the system to look at.
| You got | It means | First thing to check |
|---|---|---|
401 |
No valid credential. | For sessions: is the cookie being sent? For keys: is the prefix right — Public for pk_live_, Bearer for sk_live_? Swapping those is the single most common cause. |
402 |
The workspace is locked. Trial expired, or suspended. | Nothing you can fix in code. See access — every user in the workspace gets the same answer. |
403 |
You personally aren't allowed. | Most likely an owner-only action, or a channel you have no role on. A colleague with more rights would succeed. Also returned by ops endpoints that are disabled. |
404 |
Not found — or not in your workspace. | The two are deliberately identical. Check the id belongs to the workspace you're signed into. |
409 |
State conflict. | Read the message. Common ones: no workspace yet, quote in the wrong status, delete needs ?cascade=1, visitor already linked to somebody else. |
410 |
A one-time token is spent or expired. | Invite, password reset or join code. Issue a fresh one — they cannot be revived. |
422 |
Validation failed. | The message names the field. Note that editing a non-draft quote also lands here. |
429 |
Event ingest rate cap. | Are you firing an event per scroll or per keystroke? Debounce. The cap is per workspace per minute. |
503 |
Storage unavailable, or a feature isn't configured. | Stripe keys missing, mail driver unset, or a genuine database problem. The message distinguishes them. |
The tag isn't recording anything
Work down this list; each step rules out the one above.
-
Is the script loading?
Network tab, filter for
/tag/. A404means the slug is wrong or tracking keys have never been generated — the body will say which. Copy the URL from Settings → Web tracking rather than typing it. -
Is there a console warning?
[stitchwork] tracking key missingmeans the workspace has no public key. Generate keys. -
Is there a
__sw_vcookie?No cookie means consent has not been granted. Run
swConsent('status')in the console:'pending'— mode isrequireand nothing has granted, or a CMP was detected and hasn't answered.'denied'— either the CMP declined, or Global Privacy Control is on in the browser. GPC overrides everything, including an explicit grant.'granted'but no cookie — check you aren't looking at a different domain than the one the page set it on.
-
Are requests going out?
Look for
POST /api/v1/track/event.204is success.401means a bad key,422means a missingvisitor_tokenorurl,429means you are over the rate cap. -
Do the events carry a SKU?
Expand the request body. A
view_productwithoutprops.skuis recorded but attributed to no product — so views climb and rankings don't move. This is the most common "tracking works but nothing happens" cause.
A single-page app fires duplicate page views
The tag already wraps pushState, replaceState and
popstate, and skips a repeat of the same URL. If you are also calling
sw('track', 'page_view') from a router hook, remove it — the tag's own
handling is enough.
Recommendations are empty
Call the staff endpoint and read the diagnostics block. It answers this in
one request:
curl "https://app.stitchwork.io/api/v1/recommendations?customer_id=14" -b cookies.txt
| Diagnostic | What it means | Fix |
|---|---|---|
active_products: 0draft_products: 412 |
Your catalogue imported as drafts. Only active products rank. |
Set status in the CSV, or bulk-activate and re-import. |
linked_visitors: 0 |
This customer has never been stitched to a browser, so there is no personal history. | Use the QR handoff, or pass visitor_token when creating quotes and orders. |
view_events > 0views_on_active_products: 0 |
Events are arriving but resolving to no product — or to draft products. | Send props.sku on intent events, and check the SKUs match your catalogue exactly. |
| All zeros, healthy catalogue | The customer genuinely has no history. | Expected. A named customer returns nothing rather than global bestsellers — deliberately. |
/public/recommendations falls back to global top-rank for an unknown
visitor, so a category widget always renders. /recommendations?customer_id=
does not, so an agent is never shown bestsellers under a "recommended for them"
heading.
The order looks wrong
Open Rankings and read the input counts before touching a slider.
| Symptom | Likely cause |
|---|---|
| Everything ranks the same | All the materialised signals are zero — no orders imported and no tracked views yet, or no recompute has run since the import. |
| Old products dominate | newness weight is low and your bestseller window is long. Either is fine; decide which you want. |
| New products dominate | Every product has the same created_at — the import date. Set the real creation date in the CSV. |
| Sales history seems ignored | oldest_order_days_ago exceeds bestseller_days. Widen the window, or accept that older sales don't count. |
| One category fills the panel | Turn up category_balance. |
| Nothing changed after a big import | The materialised signals need a recompute. An order import does it automatically; a product import does not. |
Unmatched SKUs
The Rankings page lists order lines whose SKU links to no variant. Those sales contribute nothing to ranking, so a long list is a real problem.
| Cause | How to tell | Fix |
|---|---|---|
| Case mismatch | The SKU exists in your catalogue with different capitalisation. | SKU matching is case-insensitive for the unmatched report but exact elsewhere — normalise on export. |
| Separator drift | GTR-CONCERTO-WAL vs GTR_CONCERTO_WAL. | Pick one and normalise both exports. |
| Discontinued lines | Genuinely old SKUs you no longer sell. | Nothing to do — they're historical. |
| Variants never created | Configurable products imported without option columns. | Add option: columns or configurable_variations, then re-import. |
| Sanitised SKUs | skus_sanitised was non-zero on the product import. | Your source SKU contained characters we don't allow, so the stored one no longer matches. Rename at source. |
Imports
| Problem | Cause and fix |
|---|---|
413 |
Over 20 MB. Split the file, or import by URL — the ceiling applies to what we read either way, but splitting is simpler. |
422 CSV is empty |
The upload field wasn't named file, or the JSON body has neither url nor csv, or the content type didn't match the path you used. |
422 CSV has no header row |
First line isn't headers. Some exports prepend a title row — delete it. |
| Half the products became drafts | Not possible from a merge — an absent or blank status leaves the stored value alone. If it happened, the CSV had a status column with draft in it. |
| Descriptions were blanked | Also not possible from a blank cell. Check for a description column containing literal whitespace or a placeholder like -. |
Long variants_unmatched |
Child rows whose product_sku resolves to no parent. Usually a typo, or children listed before their parent in a file with no configurable_variations column. |
child_conflicts entries |
One child SKU claimed by two parents. First parent wins; fix the export. |
| Dates read wrong | Ambiguous dates are parsed day-first. Export ISO 8601 (2026-04-02) and the ambiguity disappears. |
| Orders landed in "Imported orders" | The channel column didn't match a channel name. Match the names exactly, or accept the auto-created channel and re-assign later. |
| Duplicate orders | The export changed its external_id between runs. Idempotency is keyed on (external_source, external_id) — keep both stable. |
Quotes
| Problem | Cause and fix |
|---|---|
422 editing a quote | Only draft quotes can be edited or have lines changed. Once sent, a quote is a snapshot. |
422 on send | The customer has no email. Add one and retry. |
| Sent, but no email arrived | Mail delivery is best-effort — the status change happens regardless. Check the mail driver is configured, and use GET /quotes/{id}/qr to copy the link by hand in the meantime. |
409 on accept or decline | The quote isn't sent. Someone may have already answered it. |
503 on pay | Stripe Connect isn't onboarded, charges aren't enabled yet, or the server has no Stripe keys. Check GET /billing/connect/status. |
| Paid on Stripe, still shows unpaid | The status is set by Stripe's webhook, not the browser return. Confirm the webhook endpoint is reachable and its signing secret is right. |
| Totals look wrong | Almost always the tax mode. tax_inclusive: true means the total equals the discounted subtotal and the tax figure is informational. See Totals and tax. |
| "Share browsing" doesn't appear | No join URL configured. Set it under Settings → Web tracking. |
| Browsing panel is empty | The customer has no linked visitor. Nothing to show until they're stitched. |
QR handoff fails
The reason on the redirect back tells you exactly what happened:
reason | Cause and fix |
|---|---|
no_visitor | The phone has no __sw_v cookie — they've never been on your site, or consent blocked the tag. Not fixable at the till. |
expired | The code was used, or is over five minutes old. Regenerate it; the customer scanned too late. |
wrong_org | Your join endpoint is using a secret key from a different workspace. |
visitor_other_customer | That browser is already linked to somebody else — a shared device, or a phone handed over. Refused rather than silently reassigned. |
no_customer | The quote behind the code has no customer. Shouldn't be reachable through the UI. |
network | Your join endpoint couldn't reach Stitchwork. Check egress from your server. |
unknown | Stitchwork returned a non-2xx your snippet didn't recognise. Log the full response body. |
Scans don't produce a store visit
- Open the
scan_urlfrom the QR record manually. Does the page load and does the tag fire aqr_scan? - Check the markers survived. Some URL shorteners and link wrappers strip query parameters — encode the decorated URL directly.
- Confirm the target is on a domain the tag is installed on. A QR pointing at a page without the tag records nothing.
- Remember the URL is cleaned after the event fires — seeing no
_sw_chin the address bar is the system working, not failing.
Webhooks
| Problem | Cause and fix |
|---|---|
| Nothing arrives | Is the subscription active? Do its event_types actually match — quote.accepted is not matched by quotes.* (note the plural) or by quote without .*. |
Deliveries stuck pending | Self-hosted only: the worker isn't running. Check the cron entry and the log. |
Everything dead | Six failures. Open a delivery and read last_status_code and the captured response body. |
| Signature never verifies | You are almost certainly verifying a re-serialised body. Capture the raw bytes before any JSON middleware touches them. |
| Verifies locally, fails in production | Clock skew. The tolerance is 300 seconds — check NTP on the receiving host. |
| Duplicate processing | Delivery is at-least-once. Dedupe on the event id. |
| Events out of order | Order is not guaranteed. Reconcile against the current state via the API rather than replaying the sequence. |
| Timeouts on your side | Acknowledge with 2xx immediately and queue the work. The sender's timeout is 20 seconds and it holds a lock the whole time. |
customer.updated for a new customer | Expected. Customers upsert on email, so a repeat buyer in an import fires updated, not created. |
Access and permissions
| Symptom | Cause and fix |
|---|---|
Redirected to /locked | Trial expired or workspace suspended. Public quote pages and tracking keep working throughout. |
409 No workspace | The signed-in user has never created or joined one. Send them to onboarding. |
| Admin can't edit products | Correct. Catalogue changes and imports are owner-only. |
| Member sees no channels | They hold no channel roles. Grant one on the channel's Staff tab. |
| Invite link says expired | Invites are single-use and time-limited, and resending mints a fresh token — an old link in an old email will fail. Resend and use the newest one. |
Still stuck
Collect these before asking, and the answer usually arrives in one round trip:
- The exact request — method, path, headers with secrets redacted, body.
- The exact response — status code and body. The
errorstring is written to be read. - For ranking questions: the full
diagnosticsblock, or theinputssection of/rankings/diagnostics. - For tracking questions: one captured
POST /track/eventbody, includingprops. - For webhook questions: the delivery row from
GET /webhooks/deliveries/{id}.