Stitchwork Docs
stitchwork.io

Core concepts

How Stitchwork works

Stitchwork models a retail business as seven things. Learn these and every screen, every endpoint and every webhook payload becomes predictable.

The data model at a glance

                          ┌─────────────────┐
                          │  organisation   │  one workspace, one brand
                          └────────┬────────┘
             ┌─────────────┬───────┴───────┬──────────────┐
             ▼             ▼               ▼              ▼
        ┌─────────┐   ┌──────────┐   ┌───────────┐   ┌──────────┐
        │ channel │   │ product  │   │ customer  │   │ visitor  │
        │ web     │   │  ├option │   │ (email)   │   │ (cookie) │
        │ store   │   │  └variant│   └─────┬─────┘   └────┬─────┘
        │ kiosk   │   └────┬─────┘         │              │
        └────┬────┘        │               │  customer_id │
             │             │               └──────────────┘
             │             │                      │
             │             │                      ▼
             │             │                ┌──────────┐
             │             └───────────────▶│  event   │  page_view, view_product,
             │                              └──────────┘  add_to_cart, qr_scan, …
             │
             ├──────────────▶ ┌────────┐  draft → sent → accepted → paid
             │                │ quote  │──┐
             │                └────────┘  │ convert
             │                            ▼
             └──────────────▶ ┌────────┐
                              │ order  │  paid / fulfilled / refunded / cancelled
                              └────────┘
Every table hangs off organization_id. A quote needs both a channel and a customer; an order needs a channel and may be guest (no customer).

Organisations

An organisation — called a workspace in the UI — is one brand. It owns everything else, and it is the isolation boundary: a workspace can only ever see its own data.

A user creates a workspace by naming it, and becomes its owner. The workspace carries:

slug
A URL-safe identifier derived from the name. It is what appears in your tracking tag URL (/tag/{slug}.js) and it never changes when you rename the workspace, so external references don't break.
access_state, trial_ends_at
Whether the workspace can be used at all. See how access is enforced.
tracking_public_key, tracking_secret_key
The two API credentials for storefront and server-to-server integration.
ranking_config, attribution_config
JSON tuning blobs for the recommendation engine and the attribution window.
quote_complete_url_template
Optional URL template the public quote page's "buy now" button points at.
stripe_connect_*
The merchant's own Stripe Express account, for taking card payment on quotes.
One workspace per user

A signed-in user resolves to exactly one workspace. Being invited to a second workspace joins you to it as a member; there is no workspace switcher, so people who genuinely work for two brands need two accounts.

Channels

A channel is a surface the brand sells through. Creating a workspace auto-creates one called Website; you add the rest.

FieldValuesWhat it's for
type web, store, marketplace, kiosk, pop_up, wholesale What kind of surface it is. Drives filtering and the store-visit KPI.
ownership owned, franchise, authorised, wholesale, marketplace Whose business it is. Useful when you sell through partners.
status active, paused, closed Deleting a channel closes it rather than removing it, so its quotes and orders keep their reference.
url An https:// URL The channel's front door. Channel-entry QR codes point here, and bare page views can be matched to the channel by URL.
address, country, timezone, currency Free text / ISO codes Physical context for stores. country is a 2-letter code; currency a 3-letter one.

Channels also carry a staff roster — who can work in this store, at what level — and a set of QR codes that tie a physical scan to this channel. See channel roles and QR attribution.

Products, options and variants

Three levels, and the middle one only exists for configurable products.

product
The thing a customer thinks they are buying: "Concerto Acoustic". Carries name, description, brand, category, tags, a base price, a compare-at price, a status (draft / active / discontinued), an optional parent SKU, and — importantly — a web_url.
product option
An axis of choice on a configurable product: wood with values mahogany and walnut, size with O, OO, OOO. Option names are lowercase letters, digits and underscores.
product variant
A specific purchasable combination, with its own SKU, price and status. Variants are what quote lines and order lines actually point at.

Product type decides the shape:

TypeVariantsNotes
simpleExactly one, auto-createdThe single variant inherits the parent SKU, price, currency and status. Every product has at least one variant, so downstream code never has to special-case.
configurableMany, generated from optionsDeclare options, then generate the cartesian product. Combinations that disappear from the options are marked discontinued rather than deleted.
variantManualFor catalogues that import each variant as its own row without a parent.
SKUs are the join key

SKU is how everything else finds a product: imported order lines, tracked events, external quote lines, category re-ranking. A SKU is matched against product SKUs first, then variant SKUs. A variant SKU always rolls its activity up to the parent as well, so you can rank at either grain.

Customers

A customer is a named person, unique per workspace by email. That uniqueness is load-bearing: creating a customer with an existing email updates that customer instead of failing, which is what makes order imports and external quote creation idempotent.

Customers carry name, email, phone, company, free-text notes and an arbitrary attributes JSON blob. Everything richer — what they browsed, what they were quoted, what they bought — hangs off them by reference.

Visitors and identity

A visitor is an anonymous browser, keyed by the value of a first-party __sw_v cookie on your domain. Stitchwork never reads that cookie cross-origin; the tag sends its value in the request body.

A visitor becomes a known person when visitors.customer_id is set — and their entire prior anonymous history becomes attributable at that moment. There are four ways it happens:

  1. QR handoff. The customer scans a QR on the agent's screen, bounces through a join endpoint on your domain, and their cookie is linked to the quote's customer.
  2. External quote. Your backend creates a quote and passes the shopper's visitor_token.
  3. External order. Your checkout books the sale and passes the visitor_token.
  4. In-app quote creation. A quote created with a visitor_token in its payload stitches at the same time it stamps attribution.

A visitor can only ever be linked to one customer. An attempt to re-link a visitor that already belongs to someone else is refused rather than silently reassigned.

Full detail: Identity stitching.

Events

The event stream is what everything personalised is built on. Every row carries a visitor, a timestamp, a URL, and — where they can be resolved — a product, a variant and a channel.

TypeFired byUsed for
page_viewThe tag, automatically (including SPA navigation)Timeline. The only type that falls back to URL→product matching.
view_productYou, via sw('track', …)The popular ranking signal and per-customer view affinity.
add_to_cartYouThe carted ranking signal and per-customer cart affinity.
searchYouTimeline — what the customer was looking for, in their words.
purchaseYouTimeline. Note that revenue comes from orders, not from this event.
qr_scanThe tag, when it sees QR markers on the URLStore-visit KPI and physical-channel attribution.
campaign_touchThe tag, when it sees UTM params or a click idAd attribution. Also written to a dedicated touch table.
click, customYouAnything else. A name you invent lands as custom with the name preserved.
Product attribution needs a SKU

Intent events (view_product, add_to_cart, purchase) tie to a product through props.sku, never through the URL. They fire from carts, quick-adds and carousels whose URL isn't the product's page, so URL is deliberately not trusted for them. Send the SKU.

Quotes

A quote is a proposal: a channel, a customer, some line items, totals, and a public token that lets exactly one customer see it without logging in.

   draft ──send──▶ sent ──accept──▶ accepted ──pay──▶ paid
     │              │                   │
     │              └──decline──▶ declined
     │              │
     └──cancel──────┴──▶ cancelled          (and: expired, when valid_until passes)
Only draft quotes can be edited or have lines changed. Once sent, the quote is a snapshot — line descriptions, SKUs and unit prices are frozen against later catalogue edits.

Two things people get wrong at first:

Full detail: Quotes.

Orders

An order is a sale that happened. Orders are a first-class entity, not a quote subtype, because most merchants have far more orders than quotes and most of those orders never went near a quote.

Three ways an order gets created, all landing in the same table:

created_viaSourceIdempotent on
importCSV upload(organization_id, external_source, external_id)
quote_conversion"Mark as ordered" on an accepted quoteexternal_id = "quote:{id}"
externalPOST /api/v1/orders/external from your backend(organization_id, external_source, external_id)

Order lines link to variants softly, by SKU. A line whose SKU matches nothing still records — it just doesn't feed the bestseller signal, and it shows up in the unmatched-SKU list on the Rankings page so you can fix the catalogue.

Full detail: Orders.

Ranking and personalisation

Every active product carries a score. The score is a weighted blend of signals you control, and a per-customer overlay sits on top of it.

  GLOBAL SIGNALS                         PERSONAL OVERLAY
  ├ bestseller   qty sold, 365d          ├ view    this customer's view_product events
  ├ popular      view_product, 30d       ├ cart    this customer's add_to_cart events
  ├ carted       add_to_cart, 30d        └ quote   products on their prior quotes
  ├ newness      exponential decay
  ├ featured     manual per-product      blended by personalisation_strength (0..1)
  ├ price_target proximity to target ▁▂▃▂▁         │
  └ on_sale      compare price > price            ▼
        │                                  ┌──────────────┐
        └─────────────────────────────────▶│ final order  │──▶ <sw-recs>, category
                                           └──────────────┘    pages, quote builder
Global signals are materialised by a recompute; the personal overlay and the price/sale signals are computed live at read time.

Full detail: Recommendations & ranking.

Data isolation

Three layers, in depth:

  1. Every table carries organization_id, and every model method filters on it explicitly.
  2. PostgreSQL row-level security is enabled on every tenant table. Each request sets a session variable identifying the workspace, and the policies reject rows that don't match. This is defence in depth — it catches a query that forgot its WHERE, not just a hostile one.
  3. Access resolution goes through one seam. Every route that resolves a workspace does so through the same lookup, and the access gate is called immediately after. A route cannot quietly opt out of the gate while still knowing which tenant it is serving.

That third point is also why the public surfaces are safe: the public quote page, the tracking tag and the external ingest endpoints never resolve a workspace by user at all, so they are carved out by construction rather than by a list someone has to maintain.

What Stitchwork is not