Stitchwork Docs
stitchwork.io

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 gotIt meansFirst 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.

  1. Is the script loading?

    Network tab, filter for /tag/. A 404 means 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.

  2. Is there a console warning?

    [stitchwork] tracking key missing means the workspace has no public key. Generate keys.

  3. Is there a __sw_v cookie?

    No cookie means consent has not been granted. Run swConsent('status') in the console:

    • 'pending' — mode is require and 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.
  4. Are requests going out?

    Look for POST /api/v1/track/event. 204 is success. 401 means a bad key, 422 means a missing visitor_token or url, 429 means you are over the rate cap.

  5. Do the events carry a SKU?

    Expand the request body. A view_product without props.sku is 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
DiagnosticWhat it meansFix
active_products: 0
draft_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 > 0
views_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 and staff endpoints behave differently on purpose

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

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

CauseHow to tellFix
Case mismatchThe SKU exists in your catalogue with different capitalisation.SKU matching is case-insensitive for the unmatched report but exact elsewhere — normalise on export.
Separator driftGTR-CONCERTO-WAL vs GTR_CONCERTO_WAL.Pick one and normalise both exports.
Discontinued linesGenuinely old SKUs you no longer sell.Nothing to do — they're historical.
Variants never createdConfigurable products imported without option columns.Add option: columns or configurable_variations, then re-import.
Sanitised SKUsskus_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

ProblemCause 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

ProblemCause and fix
422 editing a quoteOnly draft quotes can be edited or have lines changed. Once sent, a quote is a snapshot.
422 on sendThe customer has no email. Add one and retry.
Sent, but no email arrivedMail 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 declineThe quote isn't sent. Someone may have already answered it.
503 on payStripe 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 unpaidThe 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 wrongAlmost 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 appearNo join URL configured. Set it under Settings → Web tracking.
Browsing panel is emptyThe 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:

reasonCause and fix
no_visitorThe phone has no __sw_v cookie — they've never been on your site, or consent blocked the tag. Not fixable at the till.
expiredThe code was used, or is over five minutes old. Regenerate it; the customer scanned too late.
wrong_orgYour join endpoint is using a secret key from a different workspace.
visitor_other_customerThat browser is already linked to somebody else — a shared device, or a phone handed over. Refused rather than silently reassigned.
no_customerThe quote behind the code has no customer. Shouldn't be reachable through the UI.
networkYour join endpoint couldn't reach Stitchwork. Check egress from your server.
unknownStitchwork returned a non-2xx your snippet didn't recognise. Log the full response body.

Scans don't produce a store visit

  1. Open the scan_url from the QR record manually. Does the page load and does the tag fire a qr_scan?
  2. Check the markers survived. Some URL shorteners and link wrappers strip query parameters — encode the decorated URL directly.
  3. Confirm the target is on a domain the tag is installed on. A QR pointing at a page without the tag records nothing.
  4. Remember the URL is cleaned after the event fires — seeing no _sw_ch in the address bar is the system working, not failing.

Webhooks

ProblemCause and fix
Nothing arrivesIs 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 pendingSelf-hosted only: the worker isn't running. Check the cron entry and the log.
Everything deadSix failures. Open a delivery and read last_status_code and the captured response body.
Signature never verifiesYou are almost certainly verifying a re-serialised body. Capture the raw bytes before any JSON middleware touches them.
Verifies locally, fails in productionClock skew. The tolerance is 300 seconds — check NTP on the receiving host.
Duplicate processingDelivery is at-least-once. Dedupe on the event id.
Events out of orderOrder is not guaranteed. Reconcile against the current state via the API rather than replaying the sequence.
Timeouts on your sideAcknowledge 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 customerExpected. Customers upsert on email, so a repeat buyer in an import fires updated, not created.

Access and permissions

SymptomCause and fix
Redirected to /lockedTrial expired or workspace suspended. Public quote pages and tracking keep working throughout.
409 No workspaceThe signed-in user has never created or joined one. Send them to onboarding.
Admin can't edit productsCorrect. Catalogue changes and imports are owner-only.
Member sees no channelsThey hold no channel roles. Grant one on the channel's Staff tab.
Invite link says expiredInvites 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: