Orders
An order is a sale that happened. Orders are first-class records, not a kind of quote — most merchants have far more orders than quotes, and most of those orders never went near one.
An order also carries sales credit — which of your people earned it, and in what proportion. See Follow-ups & sales credit.
Why orders matter here
Orders do three jobs in Stitchwork:
- They are the bestseller signal. Quantity sold across orders in a revenue status in the last 365 days is the heaviest input to product ranking.
- They are revenue. The dashboard's revenue, order count and average-order-value KPIs all read orders.
- They complete the customer picture. What someone actually bought, alongside what they browsed and were quoted.
Importing a year or two of past orders is the single highest-leverage thing you can do in a trial. It turns recommendations from empty into credible on day two rather than day sixty.
The order record
| Field | Notes |
|---|---|
channel_id | Required. Which surface the sale happened on. |
customer_id | Optional — guest checkout is supported and common. |
quote_id | Set when the order came from a quote conversion. Becomes null if that quote is later deleted; the order survives. |
order_number | Your human-readable reference. Unique per workspace when set. |
external_source / external_id | The idempotency key. Together they make re-imports and retries safe. |
status | Where the sale is in its lifecycle, and the only thing that decides whether it counts as revenue. Eight values — see Order status. |
currency | 3-letter ISO code, default GBP. |
subtotal, discount_amount, tax_amount, shipping_amount, total | Snapshot totals, taken as given. Orders record what happened; they don't recompute it. shipping_amount defaults to 0.00. |
refunded_amount | How much of the sale came back. Empty means nobody has said, which is not the same as zero — a part-refunded order with no amount is held out of sales credit and counted under Unsettled refunds on your dashboard until somebody records it. Never negative, never more than the total. The Refund panel on the order page is how your team fills it in. |
amount_paid | How much has actually arrived, which is not the same as the total once you take deposits. A £4,500 order with a £900 deposit has a total of £4,500 and an amount_paid of £900, and the gap is what the customer still owes. Revenue follows this figure; average order value follows the total. See Awaiting balance. |
placed_at | When the sale happened, not when the row was written. This is what the ranking window measures against — get it right on imports. |
payment_method | How it was paid for. Free text — see Payment method and date paid. |
paid_at | When the money arrived, which is not the same thing as placed_at. Null until someone says otherwise. |
created_via | app, import, quote_conversion or external. |
attribution | Ad-touch snapshot, when a visitor token was resolvable at creation. |
shipping_amount is an order-level charge like the discount and the tax,
not a line item, and it defaults to 0.00: an order that never named a
delivery charge records none, and nothing is inferred from a line that happens to look
like postage. Where it matters is the computed total — the one we work out
when you don't supply a total yourself. That is
subtotal − discount + shipping + tax, so a delivery charge you send us
reaches the money. A total you state explicitly is taken exactly as written and never
has shipping added on top, on the assumption that your own arithmetic already
included it.
Payment method and date paid
payment_method is free text and paid_at is a
nullable timestamp. Both start empty, and nothing infers either one.
Free text is a decision rather than a shortcut. Merchants take card, finance, bank
transfer, cash, PayPal, gift card, part-exchange and store credit, with a provider name
for most of them — Klarna 12 months interest free is a real answer. A fixed
vocabulary would be a promise we couldn't keep and a migration every time you sign a new
finance provider. Nothing branches on the value: it is displayed and reported on, so an
unfamiliar word costs nothing. The order page offers your workspace's own past answers as
suggestions, which is what stops card / Card / CARD
becoming three rows in a report.
paid_at is deliberately separate from placed_at. On a card sale
they are the same second; on finance, a bank transfer or a deposit-then-balance they can
be weeks apart, and that gap is the thing a salesperson is chasing. So moving an order to
paid does not stamp a payment date — importing five years of
history would otherwise claim every sale was paid on the day of the import.
A CSV payment_method / paid_at column, the
POST /orders/external payload, or either PATCH endpoint
afterwards. A quote conversion carries the quote's own paid date across, and records
card only where the quote itself took a Stripe payment. On a patch, an
explicit null clears either field — a method typed onto the wrong order,
or a reversed payment — while a date we can't read is refused rather than filed under
today.
Both columns are COALESCEd in the upsert, the same way
attribution is: a source that says nothing about the payment is not
saying there wasn't one. These are the fields most likely to have been typed in by
hand and least likely to appear in your export, so re-running last month's CSV would
otherwise silently clear every payment method your team had recorded. Clearing them
only ever happens through a patch that names them null.
Line items carry a description, SKU, quantity, unit price, line total and a soft link to a product variant. Soft means a line whose SKU matches nothing still records — it just doesn't feed ranking, and it shows up in the unmatched-SKU list so you can fix the catalogue.
Recording a sale at the counter
Orders → Record a sale, or the same button on a customer's record — which is usually where you are when it happens, with the person in front of you. Pick who and where, type what they bought, say how it was settled.
The lines are deliberately plain: a description, an optional SKU, a quantity and a price. A SKU you type still resolves to its variant the same way an imported order's does, so a sale recorded here and one posted by your checkout end up identical. What it does not have is a product picker to search through, because a customer is standing there and a keyboard is faster.
Choose Deposit taken, balance to follow and say how much was handed over. The order records the full value as the sale and the deposit as what has arrived, and the customer appears on Awaiting balance until the rest comes in.
A deposit has to be more than nothing and less than the total — filing a £900 deposit as "paid in full" would book £4,500 you have not been given, so we will not let you.
Recording money on an order
Two panels on the order page, and between them they cover what a salesperson actually does with a sale after it has been made.
Money — what has been paid, and what is left
Order total, paid and outstanding, sitting above the status card because it is what most people opened the page to find out. Once there has been a refund it also shows what went back and what the shop kept.
Record payment takes the amount that landed — what is on the card receipt or the bank line — rather than a new running total, so nobody has to do arithmetic. Stitchwork adds it up and moves the status: short of the total leaves the order awaiting its balance, reaching the total settles it. An order you have already marked fulfilled stays fulfilled, because delivery is further along than payment and we will not tell your warehouse the thing is still waiting.
If a colleague records a payment while you have the page open, your save is refused rather than applied. Two people recording the same £3,600 is a worse problem than being asked to reload.
Refund — money going back
An amount and a reason, and the reason is required. A refund is the thing about a sale somebody asks about six months later, and the amount alone does not answer them. It is kept in your audit log with who recorded it and when.
Marking an order partially_refunded without saying how much leaves
sales credit unable to work out what to pay — so
it holds that sale back for review rather than paying your salesperson on the
full amount. Recording the figure releases it, on the net.
Refunds are measured against what the customer actually paid, not against the order total. If they cancel a £4,500 kitchen having paid a £900 deposit and you return the £900, the sale is refunded — not 20% refunded. Measured the other way, a cancelled kitchen would sit in your revenue figures for the difference forever.
Order status
One column, not two. Shopify splits payment state from fulfilment state; Stitchwork folds both into a single lifecycle, because what the rest of the app needs from an order is whether it counts as revenue and which badge to show.
| Status | Revenue? | Means |
|---|---|---|
draft | No | Started, not yet a real sale — a basket, or one of your team building an order by hand. |
pending | No | Placed, awaiting payment: a bank transfer, an unpaid invoice, a card still authorising. |
abandoned | No | Left at draft or pending long enough to give up on. |
paid | Yes | Money taken. |
partially_refunded | Yes | Money partly returned. Most of the sale stands, so it still counts. |
refunded | No | Money returned in full. |
cancelled | No | Called off before it completed. |
fulfilled | Yes | Paid and delivered — the terminal happy state. |
Those three revenue states are the whole of it: the dashboard's revenue, order-count and
average-order-value figures, and the bestseller signal behind
ranking, all filter on exactly that set. Recording
baskets and unpaid invoices as draft or pending therefore moves
no KPI at all, which is what makes them safe to send.
Words from other platforms
Every endpoint that takes a status reads the vocabulary your own system already uses,
ignoring case and punctuation — Partially Refunded,
partially-refunded and partial_refund all arrive as
partially_refunded.
| Lands on | Also read as |
|---|---|
draft | open, incomplete, checkout, basket, cart |
pending | unpaid, awaiting_payment, payment_pending, processing, authorised, authorized, on_hold, hold |
abandoned | abandoned_checkout, abandoned_cart, expired, failed |
paid | captured, success, succeeded |
partially_refunded | partial_refund, partially_refund, part_refunded |
refunded | refund, returned, chargeback |
cancelled | cancel, canceled, void, voided |
fulfilled | shipped, dispatched, delivered, collected, complete, completed, closed |
Where you named a state on purpose, an unreadable one is refused rather than guessed
at: PATCH /orders/external answers 422 and lists the eight
values. The tolerant paths are the ones that can't ask again — the CSV importer warns
and falls back, and POST /orders/external falls back to paid
rather than losing a sale mid-checkout over an unfamiliar word.
Importing from CSV
Orders → Import. Owner only. Takes a file you upload or a URL it can fetch, the same way the product importer does. 20 MB and 50,000 rows.
The format is Shopify-shaped and denormalised: one row per line item,
with rows grouped by order_number.
order_number,email,customer_name,sku,quantity,unit_price,line_total,placed_at,channel,external_source,external_id
1001,eleanor@example.co.uk,Eleanor Vance,CONCERTO-MAH-OO,1,2600.00,2600.00,2025-11-04,Website,shopify,4455661
1001,eleanor@example.co.uk,Eleanor Vance,SKETCHPAD,2,45.00,90.00,2025-11-04,Website,shopify,4455661
1002,marcus@example.com,Marcus Pemberton,SKETCHPAD,3,45.00,135.00,2025-11-06,Mayfair,shopify,4455662
Columns
| Column | Aliases | Notes |
|---|---|---|
order_number | order, order_id, name | Required. Rows sharing one become one order. |
customer_email | email | Customers are upserted by email as the import runs. |
customer_name | name | Used when creating a new customer, and to check a phone number belongs to them. |
customer_phone | phone | Optional with an email. Without one, the number finds the client — see sales known by a phone number. |
sku | variant_sku, product_sku, item_sku | Soft-linked to a variant. This is what feeds ranking. |
qty | quantity | Defaults to 1. |
unit_price | price | Money. |
line_total | total | Falls back to qty × unit_price. |
shipping | delivery, postage, shipping_cost, shipping_amount | Order-level, so it is read from the order's first row and ignored on the rest. Defaults to 0.00. A Shopify export's bare Shipping column lands here; Shipping Method is a carrier name and is not read as money. |
refunded | refunded_amount, refund_amount, total_refunded, amount_refunded | Order-level, read from the first row. No default — a file with no refund column leaves the field empty, meaning "this export does not say", rather than claiming nothing came back. Shopify's Refunded Amount lands here. |
placed_at | ordered_at, order_date, created_at | Ambiguous dates are read day-first. |
payment_method | payment, payment_type, payment_gateway, gateway | Order-level, read from the order's first row. Free text, stored as written. A Shopify export's Payment Method column lands here. |
paid_at | paid_date, paid_on, payment_date, date_paid | Order-level, read day-first like placed_at. Unlike it there is no fallback: a date we can't read warns and leaves the order unpaid, rather than claiming it was paid today. |
fulfilled_at | fulfilled_date, fulfillment_date, delivered_at, delivery_date | When it was handed over — Shopify's Fulfilled at — read day-first, for the aftercare check-in. Without one, an order the file marks fulfilled that was placed more than 180 days ago is dated by when it was placed (history being filled in, not this morning's delivery); a more recent one is dated when it turned fulfilled. An order already handed over keeps its date on a re-import. |
channel | channel_name, store, location | Matched to a channel by name. Unmatched rows land in an auto-created Imported orders channel. |
external_source | source | Half the idempotency key. |
external_id | external_order_id | The other half. Missing, it is derived from (external_source, order_number). |
description | product_name, title | What appears on the line. |
Orders are upserted on (organization_id, external_source, external_id), so
re-running last month's export updates totals and items rather than duplicating them.
You can export-and-import on a schedule without deduplicating first.
The summary
{"version":"v1","data":{
"rows_total": 8412,
"rows_ok": 8409,
"rows_failed": 3,
"orders_created": 2210,
"orders_updated": 0,
"customers_created": 1804,
"line_items": 8409,
"channel_auto_created": false,
"ranking_recomputed": true,
"errors": [{"line": 91, "message": "order_number is required."}]
}}
ranking_recomputed is the useful one: the importer triggers a full ranking
recompute when it finishes, so imported historical sales feed the bestseller signal
immediately rather than waiting for you to notice a button. If that recompute fails the
import still succeeded — the failure is reported as an error entry and you can retrigger
it by hand.
Converting a quote
The resulting order:
- Copies the quote's channel, customer, currency and all four totals.
- Copies every line, including its variant link and SKU.
- Is created as
paid, withplaced_attaken from the quote's accepted or paid timestamp. - Carries the quote's ad-attribution snapshot forward, so the conversion keeps its source.
- Links back through
quote_id, and stores the quote number inmetadata.
Only accepted or paid quotes convert; anything else is
409. Conversion is idempotent — it is keyed on
external_id = "quote:{id}", so clicking twice returns the same order.
Creating orders from your backend
For merchants whose checkout lives elsewhere. Server-to-server, authenticated with the secret tracking key — there is no session-authenticated "create order" endpoint.
curl -X POST https://app.stitchwork.io/api/v1/orders/external \
-H "Authorization: Bearer sk_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"channel_id": 2,
"customer": {"email":"ada@example.com","name":"Ada Lovelace"},
"visitor_token": "the __sw_v cookie value",
"order_number": "1001",
"external_source": "shopify",
"external_id": "gid://shopify/Order/4455661",
"status": "paid",
"currency": "GBP",
"placed_at": "2026-04-02T14:05:00Z",
"discount_amount": "0.00",
"tax_amount": "433.33",
"line_items": [
{"sku":"CONCERTO-MAH-OO","description":"Concerto in mahogany, OO","quantity":1,"unit_price":"2600.00","line_total":"2600.00"}
]
}'
What it does, in order:
- Validates that
channel_idbelongs to your workspace. - Upserts the customer by email — or, with no email, finds them by phone number (below).
- Stitches
visitor_tokento that customer and snapshots the ad-touch trail onto the order. - Resolves each line's SKU to a variant where it can.
- Upserts the order on
(external_source, external_id).
| Field | Default / behaviour |
|---|---|
channel_id | Required, must belong to the workspace. |
customer.email | Required unless customer.phone is sent. |
customer.phone | With no email, the number finds the client, and customer.name is needed for a number nobody has yet. See sales known by a phone number. |
line_items | Required, at least one. |
status | Defaults to paid, and falls back to it when the word can't be read. See Order status. |
currency | Defaults to GBP. |
placed_at | Defaults to now. Any parseable date. |
external_source | Defaults to "external". |
external_id | Derived from source + order number when omitted. Set it explicitly if you want retries to be idempotent. |
shipping_amount | Defaults to 0.00, and is added into the computed total. |
refunded_amount | Optional, and left empty when not sent. A checkout booking a fresh sale has nothing to report; a backfill of last year's orders may know that £250 came back. |
payment_method | Optional free text. Null when omitted. |
paid_at | Optional. Not defaulted to now, even when status is paid — an unpaid order must not carry a payment date, and a backfill of last year's sales must not be dated today. |
total | Computed as subtotal − discount + shipping + tax unless you supply it. A total you supply is taken as-is and never has shipping added on top. |
line line_total | Computed as quantity × unit_price unless you supply it. |
line product_variant_id | Resolved from sku when omitted. |
Sales known by a phone number
A till often knows a walk-in by name and mobile and nothing else. The orders API and the order import both take that, and decide who it is the same way Shopify's sync does. Nobody is watching an automated sale, so the rule is stricter than at the counter:
- The number and a name that agrees — the one client with that number whose name matches ("W. Blake" is Wren Blake) gets the sale.
- A number nobody has, with a name, makes a new client kept by that number.
- A number on a client's record under another name — a household landline — is not guessed at. The sale is booked without a client and the API's response carries a
notesaying so; attach it from the order in Stitchwork. - No name: the one client with that number, as an inbound text is matched. A new number with no name is refused with a
422, since there is nobody to make.
Without one, a retry after a network timeout generates a random id and creates a second order. With one, the retry updates the first — and it is the id you will name the order by every time you update it afterwards.
Updating an order from your backend
Everything that happens after checkout — the payment clears, the parcel goes out, half
of it comes back — is a PATCH to the same URL, naming the order by the id
your own system already knows it by.
curl -X PATCH https://app.stitchwork.io/api/v1/orders/external \
-H "Authorization: Bearer sk_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"external_id": "gid://magento/Order/9655",
"external_source": "magento",
"status": "shipped"
}'
Same URL as creation, same secret key, PATCH instead of POST.
PUT is accepted as a synonym for clients that can't send PATCH
and behaves identically — a partial update, never a replacement.
Fields you send are written; fields you leave out are left exactly as they were. That is the entire point of the endpoint. Re-POSTing the whole order would also reach the upsert, but it makes a backend that only knows "9655 shipped" reconstruct the customer, the channel and every line in order to say so — and quietly rewrite the sale with whatever it gets wrong on the way. A patch can only change what it names.
Naming the order
The one value every patch needs is your own id, and it can travel three ways:
# in the body — the form that always works
PATCH /api/v1/orders/external
{"external_id": "gid://magento/Order/9655", "status": "fulfilled"}
# in the query string
PATCH /api/v1/orders/external?external_id=gid%3A%2F%2Fmagento%2FOrder%2F9655
# in the path — only for an id with no slash in it
PATCH /api/v1/orders/external/1042
{"status": "fulfilled"}
The path form is the tidy one, and it only fits an id that is a single path segment. A
Magento id is gid://magento/Order/9655 and never will be: a path segment can
hold neither a bare slash nor a dependable %2F — repeated slashes are
merged and encoded ones rejected before the request ever reaches the API.
Put the id in the body unless you know yours is path-shaped. Where more
than one form is present the path wins, then the body, then the query string.
external_source is optional. It is only needed when the same
external_id exists under two sources — the same sale booked once by your
checkout and once by a CSV import, say. Without it an ambiguous id is a
409 that tells you which sources it matched, rather than a coin toss:
{
"error": "external_id \"9655\" matches 2 orders. Pass external_source to say which.",
"sources": ["magento", "shopify"]
}
An id we have never seen is a 404 pointing at POST, not a new
order. Booking a sale needs a channel, a customer and at least one line; inventing
those from a two-field patch would put a £0 order with no customer in your workspace.
What a patch may carry
| Field | Behaviour |
|---|---|
status | Any of the eight values, or a word we read as one. Unknown words are refused — see Order status. |
order_number | Your human-readable reference. null or an empty string clears it. Unique per workspace: 409 if another order already has it. |
currency | 3-letter code, upper-cased for you. Anything else is 422. |
subtotal, discount_amount, tax_amount, shipping_amount, total | Money. A JSON number is rounded to two places, so a backend that sends 59.97000000000001 is fine. A string is read the way typed money is read — "£1,499.00" works, "1.999" is refused. Negatives are allowed, for a credit or an adjustment. |
refunded_amount | Read like the other money fields, with one difference: null is accepted and means we no longer know how much came back. That is the honest thing to send when a refund is reversed or a figure is disputed, and it puts the sale back in the unsettled list rather than leaving a stale number behind it. This is usually the one fact a backend learns days after the sale. |
placed_at | Any readable date, stored as ISO 8601. This is what the ranking window measures against. |
payment_method | Free text, up to 120 characters, trimmed. null or an empty string clears it. |
paid_at | Any readable date, or null to clear it — the right patch when a payment is reversed. A date we can't read is 422, never today. Setting it does not move placed_at, and moving placed_at does not stamp a payment. |
channel_id | Must belong to your workspace. |
customer_id | Must belong to your workspace. |
customer | {"email": "ada@example.com"} — moves the sale onto a customer you already have. It looks up, it never creates: an address we don't know is 422, because a patch is correcting who the sale belongs to and a typo shouldn't quietly mint a customer to move it onto. |
quote_id | Link the order to a quote, or null to unlink it. Must belong to your workspace. It is only a link — attaching a quote does not re-price the order or copy its lines, which is what POST /quotes/{id}/convert-to-order is for. |
new_external_id, new_external_source | Change your own id for the order — for when an integration recorded the wrong one. Named new_ because a bare external_id already means "find the order with this id"; see Changing the id itself. null or an empty string clears it. |
metadata | An object, merged into what's there rather than replacing it. The same "only what you sent changes" rule, one level down. |
line_items | Replaces every line. Same shape as creation — see Replacing the lines. |
external_id and external_source in the body select the order —
they say which one, not what to change about it. To change them, see below.
Two fields are refused on both endpoints, and they are the audit trail rather than the
sale. created_via records how the row came to exist, and
attribution is a snapshot of the ad touches that drove it, taken at the
moment the order was created.
Both are records of what happened, not settings — there is no behaviour to correct by
changing them, only a record to falsify. attribution in particular is read
by the attribution report as its fallback when a
sale's live touch trail has gone (because the visitor behind it was later reassigned to
someone else), so editing it does not adjust a report — it moves credited revenue onto a
campaign that did not earn it.
Changing the id itself
An integration that recorded the wrong external_id should not have to have
the sale deleted and re-booked to fix it. But the obvious spelling is already taken:
external_id in the body means "find the order with this id". One key cannot
be both the question and the answer, so the write has its own name.
// find it by the id you sent originally, and set the one you meant
{"external_id": "1042", "external_source": "shopify", "new_external_id": "1042-A"}
new_external_id and new_external_source work on both endpoints,
and are the only way to change either column. On
PATCH /orders/{id}, where the URL already named the order, a bare
external_id is a 422 pointing at new_external_id —
it selects nothing there, and quietly ignoring it would be a 200 that
changed nothing.
Changing it changes what a retry or a re-import finds. A later call carrying the
old id no longer matches this order and will create a second one, so change
it in the system that sends it at the same time. A collision with an id another order
already holds is a 409 naming the clash rather than a silent overwrite.
What it refuses, and why
| You send | You get |
|---|---|
A misspelled field — "stauts" |
422 naming it, with the full list of fields. The alternative is a 200 that changed nothing, which is the worst answer available. |
| A status we can't read | 422, with the eight values in an expected array. A typo landing on paid would silently book revenue. |
visitor_token, attribution, created_via or quote_id |
422 explaining that the field belongs to creation — browsing is stitched and the ad-touch trail snapshotted when the order is booked, and a quote link is made by the conversion. They're named individually because a patch usually starts life as a copy of the array you already build for checkout. |
| A body that names an order and changes nothing | 422 listing what a patch may carry. |
The body is read before the database is touched, on purpose: whether your fields are readable doesn't depend on the order existing, so an integrator debugging a misspelling doesn't have to get the id right first.
Replacing the lines
line_items is a full replacement — send the lines the order should have, not
the ones that changed. Omit the field entirely and the existing lines are left alone.
SKUs resolve to variants exactly the way they do on creation, and
product_variant_id can be sent instead when you have it.
Replacement lines re-price the order the same way creating it does:
subtotalis recomputed from the lines, unless you sent one.totalbecomessubtotal − discount + shipping + tax, using the discount, shipping and tax from the same patch where present and the stored values otherwise — unless you sent a total.
Money is only recomputed when the lines change. Patching tax_amount or
shipping_amount on its own changes that column and nothing else; if the
total should move with it, send the total too.
The answer
{"version":"v1","data":{"order": { … }, "items": [ … ]}}
200 with the order as it now stands and all of its lines. A successful patch
fires order.updated carrying {order, items} — the same event a
re-import fires, so a receiver keyed on external_id needs no new handling.
| Code | When |
|---|---|
200 | Updated. |
401 | Missing or invalid secret key. It is Bearer sk_live_…, not the public key. |
404 | No order with that external_id (from that source, if you named one). Create it with POST. |
409 | The id matches orders from two sources — pass external_source; or the order_number you sent belongs to another order. |
422 | No id anywhere, nothing to change, or a field we couldn't read. The message names it. |
503 | The write didn't land. Safe to retry — the same patch applied twice is the same order. |
Like creation, this endpoint is never access-gated: a workspace whose trial has lapsed still records that an order shipped or was refunded. See what a locked workspace looks like.
Reading orders
Filters: q (order number / customer), status, customer_id, channel_id.
Repeat checkout attempts
If a recovered basket is still showing: the basket comparison relies on a
canonical snapshot of each order's lines, and orders created before this feature existed
did not have one — so they could never be matched against a later sale. That repairs
itself as the sweep runs now, but orders already checked under the old behaviour are not
re-examined on their own. Ask for a full re-check from Settings, or
POST /api/v1/orders/opportunities/scan with {"reset": true}. The
grouping and recovery columns are all derived, so a re-check costs nothing but the time.
A customer whose card keeps declining does not place one order. They place five, minutes apart, against the same basket — and until they are recognised as one thing, a £4,000 basket reads as £20,000 of recoverable demand. Stitchwork groups them automatically.
Grouping is conservative by design, and never reaches across people. Two orders are goes at the same sale only when they share a customer, are both unconverted, are in the same currency, are within 14 days of each other, and their baskets substantially overlap. Two different customers abandoning the same sofa on the same day stay two opportunities and two phone calls, whatever else they have in common. One customer abandoning a sofa on Monday and a lamp on Friday also stays two — grouping is on what was in the basket, not on the customer having been busy.
Baskets are compared by value rather than by line count, so dropping a £12 cushion between attempts does not make it a different sale.
Baskets that were bought in the end
When a sale lands, the attempts it answers are marked recovered and stop counting as money on the table. That is judged per attempt, on its own basket: a customer who abandons a sofa, then abandons a sofa and a £20,000 kitchen, then buys just the sofa still has a kitchen outstanding, and still appears.
A sale may reach back up to 90 days. Nothing about the attempt changes except the link — it
keeps its abandoned status, its line items and its place in the list, because
that is what happened.
The fields
| Field | Meaning |
|---|---|
attempt_of_order_id | The group this order belongs to. NULL on the group's own root, so an opportunity's id is COALESCE(attempt_of_order_id, id). An identity, not a claim about which attempt came first. |
recovered_by_order_id | The sale that took this basket off the table, or NULL. |
basket_signature | The order's basket in canonical form. Readable on purpose: when two attempts are grouped, the evidence is legible in the row. |
Reading demand instead of orders
GET /api/v1/orders/opportunities
One entry per piece of demand rather than per order, each being the latest attempt —
so a basket trimmed from £4,000 to £2,000 between goes is quoted at £2,000
— carrying the group's attempt count and the sibling order ids. Recovered baskets are
excluded unless you pass include_recovered=1. Filters:
q, customer_id, channel_id, since,
limit, offset.
GET /orders is unaffected and still lists every order individually. Both are
true; this is the one whose money can be added up.
Judging existing history
POST /api/v1/orders/opportunities/scan
New orders are judged as they arrive, so this is for everything that came before — every
retry cluster and every already-bought basket still sitting in your pipeline figure.
Owner or admin, batched, and safe to run repeatedly: an order already judged in its
current shape is skipped. One batch per call — keep calling while remaining
is above zero. Optional body: batch (default 2000), and reset
(also accepted as force), covered below.
You do not have to call this yourself. There is a button for it under Settings → Repeat checkout attempts, which runs the same pass and shows its progress, and a nightly job on the server keeps every workspace current afterwards. The endpoint is here for integrators who want to drive it themselves.
reset: true throws away every verdict the workspace holds before sweeping.
That is what “re-check everything” has to mean: an ordinary re-judge can only
ever join attempts together, never separate ones it has already joined, so after
a change to the grouping rules a normal pass would leave every group the old rules formed.
Send it on the first call of a loop and not on the rest, or each call would undo
the one before it. Only the fields in the table above are cleared — no order, line item,
status or total is touched, and the sweep derives them all again from what is left.
It answers with the rows it changed — grouped, recovered,
scanned and remaining — so the movement in your abandoned-basket
total can be reconciled against it. Keep calling while remaining is above zero.
A CSV import runs the same pass over the orders it touched and reports
attempts_grouped and attempts_recovered on its summary.
Nothing here can move a revenue figure. Every field is read only on the unconverted side of the status vocabulary; revenue, lifetime value and the bestseller ranking do not consult any of it.
Deleting an order
Owner-only, and real: the line items go, and the sale stops counting towards revenue and the bestseller signal. That is deliberate — this exists so imported test data can be cleared rather than lived with. A quote the order was converted from is left untouched.
Order events
order.created
order.updated
order.deleted
order.attempt_grouped
order.recovered
The last two fire only when something was actually written, so a workspace with no repeat
attempts never sees them. order.attempt_grouped carries the order that was
filed against an existing basket; order.recovered carries the sale,
with the attempts it retired under a recovered key.
Note that a re-import which updates an existing order emits order.updated,
not order.created — and so does every
PATCH /orders/external. Receivers should key on the
order's external_id, not on assuming creation order. See
Webhooks.