Stitchwork Docs

Customers & identity

A customer is a named person. A visitor is an anonymous browser. The interesting part is what happens when the two become one — and what happens to that link when things get deleted.

The customer record

A customer needs a name and a way to reach them: an email, a phone, or both. Everything else is optional. attributes is an arbitrary JSON blob carried through untouched — use it for loyalty ids, segment membership, anything your own systems need to round-trip.

Customers are unique per workspace by email. That constraint is what makes bulk order imports, external quote creation and external order creation all idempotent: creating a customer whose email already exists updates the existing record rather than failing. The response says which happened — "existing": true when the person was already here.

Clients with only a phone number

A walk-in who gives a name and a mobile is a client. Numbers are matched on their last ten digits, the same rule inbound texts use, so 07700 900123 and +44 7700 900 123 are one client however either was typed. Adding a number that is already on one client's record opens that record instead of making a second, and fills in only what it is missing — it never renames them. A number on two records already (a household landline, say) is refused with a sentence rather than guessed between.

When a client kept by their number comes back with an email — a booking on your website, an order, a row in an imported list — and the name agrees, the email joins their record. "Agrees" is strict on purpose: first names that match (or an initial), and surnames that match when both have one. "Mr Patel" on the landline and "Priya Patel" booking from it do not agree — a title and a surname are nobody's first name — so the booking becomes its own record, and the same number with the same name is put to a person as possibly the same person rather than merged.

A quote can be sent to a client with no email: it is marked sent, and the person sending is offered Text the link, which opens their message composer with the link in it. The server-to-server APIs (external quotes and orders) still key on an email.

Searching the customer list matches name, email, company and phone — a number however it is typed.

The client book

A customer's Overview leads with the person rather than the paperwork: who looks after them, what they have spent, what is open, when they are next in, when anyone last spoke to them and what they keep looking at — then what the team knows about them and the dates that matter. It is the card to read before a call.

Who looks after them

A client can have an associate: the person on the team they deal with. Follow-ups about that client go to their associate first — ahead of whoever raised their open quote — and so do the reminders for their dates. A member can take a client nobody looks after (Make them my client) or hand back their own; an owner or admin can assign anyone. When somebody leaves the team their clients go back to nobody, rather than to a seat that no longer exists. The customer list filters to My clients or Unassigned and shows the associate on every row; changing who looks after a client is written to its audit log.

Tags

Short labels — VIP, Trade, Bridal — up to twenty a client, matched without regard to case, so "vip" and "VIP" are one tag. The customer list filters by tag, and the tags already in use are offered as you type, so the same thing is not spelled four ways.

What we know

The details a good associate remembers, kept so that the whole team does: a ring size, an inside leg, a preferred finish, the room a piece is for. One value per label — writing "ring size" again corrects the one there rather than adding a second — with who last changed it. The assistant reads them before it drafts anything, through get_customer_brief.

Dates that matter

A birthday, an anniversary — given the year, it says which one — or any other date with a name: "Moving in", "Wedding". They recur every year unless marked as a one-off, and a 29 February birthday falls on the 28th in other years. The nightly sweep raises a follow-up ahead of each (see reasons to get in touch), and Today lists what is coming up in the next fortnight.

PUT    /api/v1/customers/{id}/associate     {"user_id": 7}   — or "me", or null
PUT    /api/v1/customers/{id}/tags          {"tags": ["VIP", "Bridal"]}
POST   /api/v1/customers/{id}/facts         {"label": "Ring size", "value": "N½"}
DELETE /api/v1/customers/{id}/facts/{factId}
POST   /api/v1/customers/{id}/dates         {"kind": "birthday", "month": 10, "day": 9}
DELETE /api/v1/customers/{id}/dates/{dateId}

What each client has agreed to hear about — new pieces, events, offers — by email, text, WhatsApp, phone call and post, with the date and how you know. It is a ledger: every statement is kept, a correction is a new statement, and the database refuses an edit. What is true now is the latest statement for each channel; a channel nobody asked about is Not asked, which is never treated as a yes.

Anyone on the team records one from the Marketing permissions card on the Overview — agreed or said no, told in person or on a signed form, and the day — and the new-client form asks while the client is there. Some arrive on their own:

It blocks nothing. A client is owed messages about their own order, appointment or repair whatever they said about offers, and the person writing knows which they are writing — so the composer says what is on file instead: quietly when nobody asked, plainly when the client said no. The assistant reads the same record through get_customer_brief and is told that only agreed permits news and offers. A client's data export includes every statement.

GET  /api/v1/customers/{id}/consents
POST /api/v1/customers/{id}/consents   {"channel": "email", "granted": true, "source": "in_person",
                                        "note": "At the private view", "given_on": "2026-10-04"}

Notes & contact — who recorded what, and when

Notes on a customer are an attributed log. Each entry keeps its author and the moment it was written, so "Marcus asked for the walnut sample, recorded by Priya on 3 June" stays a fact rather than fading into an anonymous text box. The card on the customer page writes here, and every entry also appears on the activity timeline under its own note group.

Logging a call, a visit, a message

Every entry has a kind, and it is the same form either way — type what happened and pick what it was. A plain Note is something you know about the customer; Call, Showroom visit, Email and Message are contact that actually took place.

The distinction earns its place on the file rather than in a report. Six calls and a visit read differently from six notes at a glance, before anybody reads a word of the bodies — which is why only contact is marked, and a plain note wears nothing.

Friday's call, written up on Monday

Pick a kind other than Note and a when it happened field appears. Use it. Writing a conversation up when you are back at a desk is the ordinary case, and without the date the file would say you spoke to them on Monday — which reads as more recent contact than there was, in the one direction that matters.

The row then shows both: the day it happened, and "logged" with the day it was typed. The file sorts by when things happened, so a back-dated call lands in the right place rather than at the top.

Your own words are kept. "phone", "walk-in", "showroom", "WhatsApp" and the rest are understood and filed as the right kind, and anything we genuinely cannot read is saved as a plain note rather than refused — losing what a customer said because of a word is the wrong trade.

Your team writes them on the customer page, and an AI agent can read and record them through the MCP connector — a note written by a person's seat is recorded as that person, one written by a bot is recorded under the bot's name and marked as AI-written. Nothing is ever attributed to somebody who did not write it.

The notes profile field shown above still exists and still works — it now holds the text of the most recent note, and the two stay in step in both directions. A note added to the log updates the field; a changed notes value arriving through a CSV import or external order/quote creation is recorded in the log as a new entry (unattributed, since there is no person behind those). Notes written before the log existed were migrated into it as their customer's first entry.

Correcting and removing a note

A customer file is only worth reading if it is current, so a note can be corrected or taken off the file. What keeps that honest is that nothing changes quietly: every change is recorded in the audit log with who made it and the text either side of it, and a note that has been corrected is marked as edited wherever it is shown.

ActionWhoWhy
Edit The author, and nobody else — not an admin, not the owner. A note is signed. Editing it changes what a named person is on record as having said, and there is no version of "the owner edited Priya's note" that leaves Priya's name on it honestly.
Delete The author, or a workspace owner or admin. Nothing is put in anyone's mouth by a delete. The note stops being shown; the log keeps what it said and who removed it.

Which leaves the case this exists for — a note that is now wrong and whose author has left. Delete it and write a current one, under the name of whoever knows it to be true. A note with no author at all (an import, an integration, an AI agent seat) has nobody to correct it, so nobody edits it; an owner or admin can still remove it.

# correct a note — the author only
curl -X PATCH https://app.stitchwork.io/api/v1/customers/42/notes/31 \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"body": "Delivery agreed for 12 September."}'

# remove it — the author, or a workspace owner/admin
curl -X DELETE https://app.stitchwork.io/api/v1/customers/42/notes/31 -b cookies.txt

# what has been done to it, oldest first
curl "https://app.stitchwork.io/api/v1/customers/42/notes/31/history" -b cookies.txt

Deletes are soft. The note disappears from every read path — the customer page, the timeline, this API and the MCP tools — and the notes profile field re-derives from whatever note is now newest, so removed words don't survive there. What it said is kept behind the audit log, which is what makes a delete evidenced rather than merely done.

Each note in the list carries can_edit and can_delete for whoever is asking, so a client renders the buttons it will actually be allowed to use. A note that has been corrected carries an edited object with the time, the editor and how many times.

The audit log

Every change to a note lands in the workspace audit log: who did it, when, and the text before and after. /audit in the app is the page; the API is GET /api/v1/audit-log, filterable by entity_type, action (created, updated, deleted) and actor_user_id.

The workspace-wide feed is limited to the workspace owner and admins — it is a view of what every colleague has been doing. The history of a single note is on the note itself and readable by anyone who can read the customer, because a history only settles an argument if the people having it can look.

Two things it is built to keep. An entry records the actor's name as it was at the time, not a link resolved when you read it, so a colleague leaving does not take their history with them. And a change cannot land unlogged: the record and its log entry succeed together or not at all.

# everything deleted in this workspace, newest first
curl "https://app.stitchwork.io/api/v1/audit-log?action=deleted" -b cookies.txt

The activity timeline

Everything a customer has done, newest first, in one stream — and "everything" is meant literally. Eight sources are merged:

GroupWhat lands in it
webPage views, product views, cart adds, searches, clicks, on-site purchases and your own custom events — each carrying the page they happened on and the referrer that brought them.
campaignUTM-tagged and ad-click arrivals, broken out: source, medium, campaign, term, content, the gclid/fbclid/msclkid and the landing page.
storeQR scans — a physical visit to a named channel, down to which QR code was scanned.
appointmentBooked visits, placed at the time of the visit itself: requested, confirmed, completed, cancelled, no-show.
noteNotes recorded on the customer — what your team wrote, who wrote it and when.
quoteCreated, sent, opened by the customer, accepted, declined, paid — plus any notes your team wrote on it.
orderPlaced, and paid. Two rows, because on finance or a bank transfer they are two moments — see payment method and date paid.
identityThe customer record being created, and the moment an anonymous trail was linked to it.

/timeline returns entries, a summary and the groups that filter it. Each entry is already shaped for display — a title, a headline, a link, a money badge and a list of label/value pairs — and dated in ISO 8601, which every browser parses the same way. limit is clamped to 1–500 and defaults to 250.

{"version":"v1","data":{
  "entries": [
    { "id": "order-21-paid", "at": "2026-05-02T14:00:00+00:00",
      "group": "order", "kind": "order_paid", "icon": "£",
      "title": "Payment received · Novuna 24mo 0%",
      "headline": "1042", "href": "/orders/21", "badge": "GBP 4,000.00",
      "meta": [ {"label": "Method", "value": "Novuna 24mo 0%", "href": null} ] }
  ],
  "summary": { "revenue": 4000, "orders": 1, "quotes": 1, "quotes_viewed": 1,
               "page_views": 42, "campaigns": 3, "first_seen": "…",
               "known_days": 151, "days_since_last": 2 },
  "groups": [ {"key":"all","label":"Everything","count":61},
              {"key":"web","label":"Browsing","count":45} ]
}}

/events is the older, narrower endpoint and is unchanged: raw event rows, limit clamped to 1–200 and defaulting to 50, types a comma-separated list of event types. Matched products are joined inline, which is what lets the quote builder render add-to-quote cards straight off a browsing row.

Nothing is deduplicated

Repeat views are repeat rows. Collapsing them into "× 7" reads well and destroys what a timeline is for: six views over five weeks and six views in four minutes are different prospects, and a tally cannot tell you which one you have. The single exception is scroll_depth, which fires several times a page and says nothing.

The summary counts everything, not the page

limit windows the entries; the figures above them describe the whole history. Otherwise "3 page views" is a lie told by pagination. Revenue uses the same revenue statuses as the dashboard, so the two can never disagree about what a customer is worth.

The timeline spans devices

It covers every visitor linked to this customer, not just the one in front of you. Someone who browsed on their phone, scanned a QR in store and then bought on a laptop appears as one continuous history.

Visitors

A visitor is one browser on your domain, identified by the value of the first-party __sw_v cookie. Stitchwork never reads that cookie cross-origin — the tag sends its value in the request body, and the server only ever sees a token.

Each visitor row carries:

visitor_token
The cookie value. Unique per workspace.
customer_id
Null until stitched. Setting it is what turns anonymous browsing into a named history.
first_seen_at, last_seen_at
Bumped on every event.
user_agent, ip_hash
The IP is never stored raw — only a salted SHA-256 hash. There is no column anywhere that holds a customer's IP address.

The cookie has a sliding two-year expiry: every event re-writes it, so an actively returning customer keeps the same identity indefinitely.

Identity stitching

Six routes from anonymous to known. All of them do the same thing in the end, and all of them work backwards as well as forwards: the moment a browser becomes a named person, every event that browser ever fired is attributed to them. Three weeks of research doesn't start counting from the introduction — it is all there, from the first visit.

1. The in-store handoff

This is the one that changes a conversation. The associate has a quote open. The customer identifies themselves from their own phone, in seconds, on their own terms — and the research they have been doing at home for three weeks lands in front of the associate while they are still standing there.

From the customer's side there is no app, no account and no form: they act once, and what they see next is their own quote. From the associate's side, the browsing panel fills in — what this person has been considering, what they ruled out, what they came back to twice — and the recommendations beside it become recommendations for them. A successful handoff is recorded on the quote's timeline, so the team can see it happened and who was there.

It needs one small route on your own domain, which we set up with you during onboarding — it is a few lines, and it is the same few lines whatever you run. Your workspace won't offer the handoff on quote pages until it is in place.

Ask us to show you this one

The sequence behind it is the sharpest thing Stitchwork does and we don't publish it. Your implementation pack has everything your developer needs, and it lands better as a demo anyway — get in touch and we will walk your team through it end to end.

2. External quote creation

Your website builds a basket, the shopper clicks "reserve in store", and your backend posts the quote — passing the __sw_v cookie value it can read on its own domain. The visitor is linked before the agent ever opens the quote.

curl -X POST https://app.stitchwork.io/api/v1/quotes/external \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel_id": 7,
    "customer": {"email":"ada@example.com","name":"Ada Lovelace"},
    "visitor_token": "the __sw_v cookie value",
    "line_items": [{"sku":"CONCERTO-MAH-OO","quantity":1,"unit_price":"2600.00"}]
  }'

3. External order creation

Same idea at the point of sale. Your checkout books the order and passes the visitor token; Stitchwork upserts the customer by email, stitches the visitor, and snapshots the ad-touch trail onto the order.

4. In-app quote creation

A quote created through the normal API can carry a visitor_token too. It stitches and stamps attribution at the same moment the quote is created.

5. A nudge they answered

A nudge that asks for an email address is the one route here that starts with the customer rather than with your team or your systems. The address goes through the same door as sw('identify'), so a shopper who types it into a bar on your storefront is stitched exactly like one who types it into your live chat: the whole prior trail attaches, retroactively, and shows up on the timeline.

A nudge asking for a phone number is not an identification — a number is not a customer key. Where the browser is already stitched, it opens a follow-up instead. Where it is anonymous, the number is held against the nudge and counted as a callback waiting on a name, rather than becoming a customer record built around an invented address.

6. Your contact form

A message through your own contact form (POST /api/v1/contact) is an identification that happens to carry words. The address goes through the same door as sw('identify') — so the trail attaches retroactively, exactly as above — and the message itself is stored on the contact and emailed to your team.

This is the route where the backwards half pays for itself most obviously. An enquiry arrives reading “do you do this in walnut?”, and the notification carries a link to somebody who has opened the walnut range nine times this week. The reply starts from that rather than from nothing.

Post it with your pk_live_ key and include the visitor's visitor_token so the stitch has something to attach; a submission without one still records the contact and the message, it just has no trail to bring with it. See the API reference for the full body.

Stitching is soft-failing everywhere

If the token doesn't resolve, or the visitor already belongs to someone else, the quote or order is still created. Identity is valuable; it is never worth losing a sale record over.

Identity — one person, many addresses

A customer is unique by email, and that uniqueness is what makes imports and external orders idempotent. But a person is not an address. Somebody signs up to the newsletter as anna@home, comes back on the same laptop and checks out as anna@work: two records, the browsing on one and the basket on the other, and two rows on the lead desk for one phone call.

Three things on the customer page close that:

Not the same person is the other answer, and anyone can give it: a household iPad, a phone passed across a desk. A dismissed pair stays dismissed — the same browser will keep refusing the same link, and the question is not asked again.

No AI connection can add an alias, merge, or dismiss. An agent reading a customer's brief can see that a question is open; a person answers it.

GET    /api/v1/customers/{id}/identity
POST   /api/v1/customers/{id}/identities            {"email": "anna@work.example"}
DELETE /api/v1/customers/{id}/identities/{identityId}
POST   /api/v1/customers/{id}/merge                 {"into": 42}      — {id} is folded into 42
POST   /api/v1/customers/matches/{matchId}/dismiss  {"note": "Shared laptop."}

Campaign attribution

Every time the tag sees UTM parameters or an ad-platform click id (gclid, fbclid, msclkid) on a landing URL, it records a touch against the visitor — source, medium, campaign, term, content, click id, landing URL and referrer.

Two things then happen:

When a quote or an order is created with a resolvable visitor_token, a snapshot of that trail is stamped onto the conversion — {"first_touch":{…},"last_touch":{…},"touch_count":N} — so it keeps its attributed source even if the visitor's identity is later reassigned.

The window and default model are workspace settings, under Settings → Attribution.

conversion_window_days is clamped to 1–365 (default 90). model is one of time_decay (default), linear, position_based, last_touch, first_touch. Touches are captured regardless of these — the settings govern how they are credited.

Deduplication is client-side: a 30-minute __sw_attr cookie stops a refresh or a same-visit return to the landing URL re-recording the same touch. UTM parameters are deliberately not stripped from the URL, because your own analytics expect them.

The attribution report

Attribution in the sidebar reads the touches back as sales. Every order in the window that counts as revenue has its credit split across the touches that preceded it, and the result is grouped by source, source/medium or campaign.

The report is one of the optimisation tools — part of Collection, and an add-on to Independent. The touches it reads are recorded on every plan, so it opens on your whole history the day it is switched on.

The window at the top picks which sales are counted — the same calendar windows the dashboard uses, so "month to date" means the same days in both places. How far back from each of those sales its touches may reach is a different number: your workspace conversion window, set in Settings and shown on the report rather than being a control on it.

Credit is fractional

A sale with four touches on it is not four sales. Each sale contributes exactly one order and exactly its own total, split across its trail — so the Credited orders column holds decimals, and it adds back up to the number of attributed sales above it. A row reading 0.60 means it took most of the credit for one sale, or a share of several; the Sales column beside it says how many whole sales it had any hand in.

Credited revenue will not reconcile to the penny

It is a share of each sale, so a quarter of £999.99 is not a round number of pence. The figures are carried at full precision and rounded only for display. As everywhere else in Stitchwork, revenue is reported per currency and never added across currencies — there are no exchange rates here to add them with.

Switching models

The model picker overrides your workspace default for that view only; nothing is saved. That is the point of it — the five models are five different opinions about an unobservable, and the useful question is not "which is right" but "how much does my conclusion move when I change the opinion behind it". A campaign that tops the table under all five is telling you something a campaign that only tops it under last_touch is not.

ModelSplits credit
time_decayFalls off exponentially with distance from the sale, on a half-life you set (default 7 days). The default.
linearEvenly across every touch.
position_based40% to the first touch, 40% to the last, 20% shared by the middle. Two touches split it 50/50.
last_touchAll of it to the final touch.
first_touchAll of it to the first.

What it admits to

The coverage row at the top is not decoration. Most sales in most workspaces have no ad touch behind them at all — walk-ins, repeat customers, anyone who arrived before the tag was deployed or on a device that never carried your cookie. Those are counted as unattributed rather than dropped, because a report that drops them turns "12% of sales came from paid search" into "paid search drove everything we can see", and those read as the same sentence.

Two further notes appear under it when they apply:

There is no cost in this report

Stitchwork has never talked to an ad account and holds no spend, so it can show you no CPA and no ROAS. This is the numerator; bring the denominator from the platform you bought the clicks on.

Bringing a client list in

Customers → Import clients (owners and admins) reads the book a shop already keeps: a Shopify customers export, a till's client list, or the spreadsheet with "Stylist" and "Ring size" columns that has been kept by hand for years. Columns are matched by their heading, loosely — E-mail Address and email are the same column — and a name may come whole or as first and last. Commas, semicolons and tabs all work as separators, and a file saved by Excel on Windows is read as what it says.

It shows what will happen before anything does. Check the file reads it the same way the import will and writes nothing: how many clients are new, which already here get what filled in, which rows cannot be used and why. Any column it does not read on its own is listed, and can be ticked to keep under What we know — which is where "Ring size" belongs.

It adds and never overwrites. A client is matched by email — including any of their other addresses — or, on a row with no email, by phone number; a number on several clients is left out and said so. A client already here keeps their name, phone, associate, birthday and every fact the team has typed. The file fills only what is blank, adds tags, hands an unassigned client to the associate it names (by their name or email), and adds a note only if that note is not already on them. So running the same file twice changes nothing the second time. Birthdays come with or without a year — 9 October, 09/10, 1985-10-09 — and are read day-first.

Each row needs an email or a phone number; one with neither is listed as not imported rather than skipped silently. A walk-in kept by their number who appears later in the list with an email, under a name that agrees, gets the email rather than a second record.

Marketing consent columns — Accepts Email Marketing and Accepts SMS Marketing from a Shopify export, or Email opt-in, Newsletter and the like — are read strictly: yes or subscribed is recorded as agreeing, unsubscribed or opted out as withdrawn, both dated today and marked as coming from the import. A plain no records nothing: it means nobody ticked the box, not that the client refused. And a client who has already said something on a channel keeps it — a list cannot date its yes, so it never undoes last week's STOP.

POST /api/v1/customers/import      file=@clients.csv  dry_run=1                — the preview, whole file
POST /api/v1/customers/import      file=@clients.csv  facts[]=Ring size        — writes the first 1,000 rows
POST /api/v1/customers/import      file=@clients.csv  facts[]=Ring size  from=1000   — …and on, until next is null

An import writes a thousand rows a call and answers with next, the row to carry on from — null when the file is done — so no single request runs long enough to be cut off. The page does this for you, with progress on the button.

Everything you hold about someone

When a client asks what you hold about them — a subject access request, which has to be answered within a month — Download everything we hold on their record (owners and admins) gives it as one file: the record and their other addresses, what the team knows and their dates, notes, follow-ups, quotes and orders with every line, appointments, every email and text and what happened to it, messages from your website, the browsing and ad visits linked to them, the products they keep coming back to, their opt-outs, every marketing permission they gave or withdrew, and conversations with the assistant about them.

Every part is read on its own, and one that cannot be read is named in the file's unavailable list rather than quietly missing. Keys that open things — a quote's public link, a tracking token — are left out. It includes what the team wrote about the person, so read it before you send it on; each download is written to the client's audit log.

GET /api/v1/customers/{id}/export      — JSON, as a download

Deleting a customer

Owner-only, and deliberately two-step. Because a quote's or an appointment's customer reference is restrictive — a stranded quote is worse than none — deleting a customer with history takes their quotes, orders and appointments with them. The first call refuses and tells you exactly what would go:

HTTP/1.1 409 Conflict

{"error":"This customer has 3 quotes, 1 order and 2 appointments. Deleting them removes those too, and the totals they count towards.",
 "data":{"requires_cascade":true,"dependents":{"quotes":3,"orders":1,"appointments":2}}}

Repeat with ?cascade=1 to go ahead. It runs in one transaction, so a half-deleted customer is not possible.

Orders survive their originating quote — orders.quote_id simply becomes null — so tidying a quote away never destroys the sale recorded behind it.

Deleting never cuts the tracking connection

Delete a quote and the visitor stays recognised as that person; only the quote's spent one-time join codes go with it. Delete a customer and the visitor is detached, not destroyed: the cookie, its events and its ad touches survive as anonymous browsing, ready to be identified again by a later quote.

Recommendations for a customer

The customer detail page and the quote builder both show a list built for the person in front of you, and get_customer_brief carries the same suggestions for an agent.

Naming a customer is a request for that person's list, so the result is restricted to products they have actually engaged with. If they have no history, you get an empty list rather than the global bestsellers dressed up as personal — an agent reading a list aloud has no way to tell those two apart otherwise.

Every staff call returns a diagnostics block explaining the result. See Diagnostics.

In the app this is a tab of its own on the customer page rather than a panel at the top of Activity. It answers a different question — what to show them next, not what they did — and it was taking the top of a tab whose whole job is the stream.