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:
| 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 stored 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. To split between particular categories and push the others down, name them in category_balance_include (the tick-boxes under the slider). |
| Nothing changed after a big import | The stored signals need a recompute. An order import does it automatically; a product import does not. |
A nudge isn't showing
Most of the time this is the feature working. Work down the list in order — each line rules out the one below it.
| Check | Why it stops the message |
|---|---|
Is it live? |
Draft and paused nudges are never served, and a start or end date that has not arrived or has passed does the same thing quietly. Save & turn on, not Save as draft. |
| Is the tag installed and consented? | A nudge rides on the tracking tag and waits behind the same consent gate as every event. No consent, no __sw_v cookie, no nudge. Global Privacy Control blocks it outright. |
| Has this browser seen it before? | The default frequency is once per visitor, ever. A dismissal or a response also stops it permanently. Test in a fresh profile, not a reload. |
| Are you in the holdout? | One matching visitor in ten sees nothing, and there is deliberately no way to tell from the page. The arm follows the visitor token, so it will not change on refresh — try a different browser profile. |
| Does the rule actually match you? | Read the rule back as the sentence on the nudge row rather than re-deriving it — it states every condition, window included. visit.seconds_since_action is the usual culprit: a tester clicking around is never idle for 90 seconds. So is a threshold seeded from buyers who looked at nine products, and a within_days shorter than the browsing you are testing with. |
| Is a higher-priority nudge winning? | Where several live nudges match, one is shown. Pause the others while you test. |
An unknown fact, an unknown operator or a malformed condition evaluates to no match — never to a match and never to an error, because the alternative failure mode is broadcasting to every visitor you have because of a typo. If a rule written through the API matches nobody at all, check the fact names against the list.
The report says it cannot tell
That is a real answer, not a failure. counting means an arm is still under
30 visitors — the note says how many each has. unmeasured means the holdout
is set to 0%, so there is no control group and no lift can be offered; set it back to
10% and the next visitors start populating both arms. inconclusive means
there is a difference but not a confident one, and the note tells you how many more
visitors per arm would settle a difference that size.
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. |
Updating orders from your backend
| Problem | Cause and fix |
|---|---|
401 on a PATCH that used to work |
The secret key was rotated, or the header carries the public one. It is Authorization: Bearer sk_live_…; pk_live_ keys are never accepted here. |
404 for an order you can see in the app |
You are patching by your external_id, not our numeric id. If the order arrived through a CSV import with no external_id column, its id was derived as {external_source}:{order_number} — csv:1001 for a file that didn't name a source either. The order's detail page shows both values as stored. |
404 only when the id is in the path |
The id has a slash in it. gid://magento/Order/9655 is not a path segment, and an encoded %2F is rejected before the request reaches the API. Send external_id in the body instead. |
409 … matches 2 orders |
The same id exists under two sources — usually the same sale booked once by your checkout and once by an import. Pass external_source; the response lists the ones it found. |
422 Unknown order status |
The word isn't one of the eight, or a synonym of one — the response lists them. Unlike creation and the importer, a patch will not fall back to paid. See Order status. |
422 'visitor_token' can only be sent when the order is created |
The patch was built from the checkout payload. Drop visitor_token, attribution, created_via and quote_id — those are recorded once, when the sale is booked. |
422 Nothing to update |
The body named an order but every other key was a selector or was dropped. Send at least one field, e.g. {"status":"shipped"}. |
| Totals didn't move after replacing lines | They only move when you let them. A subtotal or total in the same patch is taken as given; omit them and both are recomputed from the new lines. |
| Revenue jumped or vanished | Only paid, partially_refunded and fulfilled count. Patching an order to refunded or cancelled removes it from revenue, AOV and the bestseller signal — which is the intended behaviour. |
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 customer comes back to their quote page either way, and a failed handoff says why. What it means, and whether it is worth a second try at the till:
| What happened | What to do |
|---|---|
| They have never browsed your site, or your consent banner blocked tracking on their last visit. | Nothing to hand over yet. Not fixable at the till — carry on with the quote. |
| They took too long, so the handoff timed out. | Start it again. It is deliberately short-lived. |
| That browser is already linked to a different customer — a shared device, or a phone passed across. | Refused on purpose rather than silently re-pointed. Leave it; a browser belongs to one person. |
| Your join route can't reach Stitchwork, or is configured with a key from another workspace. | One for your developer — check the route's outbound access and which key it holds. Your implementation pack covers both. |
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}.