Core concepts
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.
Why orders matter here
Orders do three jobs in Stitchwork:
- They are the bestseller signal. Quantity sold across
paidandfulfilledorders 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 | paid, refunded, cancelled, fulfilled. Only paid and fulfilled count towards revenue and ranking. |
currency | 3-letter ISO code, default GBP. |
subtotal, discount_amount, tax_amount, total | Snapshot totals, taken as given. Orders record what happened; they don't recompute it. |
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. |
created_via | app, import, quote_conversion or external. |
attribution | Ad-touch snapshot, when a visitor token was resolvable at creation. |
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.
Importing from CSV
Orders → Import, or POST /api/v1/orders/import. Owner
only. Same three ingestion paths as the product importer: multipart upload, a JSON body
with url or csv, or a raw text/csv body.
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. |
customer_phone | phone | Optional. |
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. |
placed_at | ordered_at, order_date, created_at | Ambiguous dates are read day-first. |
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
curl -X POST https://app.stitchwork.io/api/v1/quotes/7/convert-to-order -b cookies.txt
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.
- 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. |
line_items | Required, at least one. |
status | Defaults to paid. Anything outside the four valid values falls back to paid. |
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. |
total | Computed as subtotal − discount + tax unless you supply it. |
line line_total | Computed as quantity × unit_price unless you supply it. |
line product_variant_id | Resolved from sku when omitted. |
Without one, a retry after a network timeout generates a random id and creates a second order. With one, the retry updates the first.
Reading orders
# list, filtered
curl "https://app.stitchwork.io/api/v1/orders?status=paid&customer_id=14&q=1001" -b cookies.txt
# one order with its line items
curl https://app.stitchwork.io/api/v1/orders/91 -b cookies.txt
Filters: q (order number / customer), status, customer_id, channel_id.
Deleting an order
curl -X DELETE https://app.stitchwork.io/api/v1/orders/91 -b cookies.txt
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
Note that a re-import which updates an existing order emits order.updated,
not order.created. Receivers should key on the order's
external_id, not on assuming creation order. See
Webhooks.