Stitchwork Docs

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:

Import history first

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

FieldNotes
channel_idRequired. Which surface the sale happened on.
customer_idOptional — guest checkout is supported and common.
quote_idSet when the order came from a quote conversion. Becomes null if that quote is later deleted; the order survives.
order_numberYour human-readable reference. Unique per workspace when set.
external_source / external_idThe idempotency key. Together they make re-imports and retries safe.
statusWhere the sale is in its lifecycle, and the only thing that decides whether it counts as revenue. Eight values — see Order status.
currency3-letter ISO code, default GBP.
subtotal, discount_amount, tax_amount, shipping_amount, totalSnapshot totals, taken as given. Orders record what happened; they don't recompute it. shipping_amount defaults to 0.00.
refunded_amountHow 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_paidHow 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_atWhen the sale happened, not when the row was written. This is what the ranking window measures against — get it right on imports.
payment_methodHow it was paid for. Free text — see Payment method and date paid.
paid_atWhen the money arrived, which is not the same thing as placed_at. Null until someone says otherwise.
created_viaapp, import, quote_conversion or external.
attributionAd-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.

Where to set them

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.

A re-import never wipes them

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.

Taking a deposit

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.

Why the amount matters more than it looks

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.

StatusRevenue?Means
draftNoStarted, not yet a real sale — a basket, or one of your team building an order by hand.
pendingNoPlaced, awaiting payment: a bank transfer, an unpaid invoice, a card still authorising.
abandonedNoLeft at draft or pending long enough to give up on.
paidYesMoney taken.
partially_refundedYesMoney partly returned. Most of the sale stands, so it still counts.
refundedNoMoney returned in full.
cancelledNoCalled off before it completed.
fulfilledYesPaid 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 onAlso read as
draftopen, incomplete, checkout, basket, cart
pendingunpaid, awaiting_payment, payment_pending, processing, authorised, authorized, on_hold, hold
abandonedabandoned_checkout, abandoned_cart, expired, failed
paidcaptured, success, succeeded
partially_refundedpartial_refund, partially_refund, part_refunded
refundedrefund, returned, chargeback
cancelledcancel, canceled, void, voided
fulfilledshipped, dispatched, delivered, collected, complete, completed, closed
A word we can't read never quietly becomes "paid"

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,[email protected],Eleanor Vance,CONCERTO-MAH-OO,1,2600.00,2600.00,2025-11-04,Website,shopify,4455661
1001,[email protected],Eleanor Vance,SKETCHPAD,2,45.00,90.00,2025-11-04,Website,shopify,4455661
1002,[email protected],Marcus Pemberton,SKETCHPAD,3,45.00,135.00,2025-11-06,Mayfair,shopify,4455662

Columns

ColumnAliasesNotes
order_numberorder, order_id, nameRequired. Rows sharing one become one order.
customer_emailemailCustomers are upserted by email as the import runs.
customer_namenameUsed when creating a new customer, and to check a phone number belongs to them.
customer_phonephoneOptional with an email. Without one, the number finds the client — see sales known by a phone number.
skuvariant_sku, product_sku, item_skuSoft-linked to a variant. This is what feeds ranking.
qtyquantityDefaults to 1.
unit_pricepriceMoney.
line_totaltotalFalls back to qty × unit_price.
shippingdelivery, postage, shipping_cost, shipping_amountOrder-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.
refundedrefunded_amount, refund_amount, total_refunded, amount_refundedOrder-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_atordered_at, order_date, created_atAmbiguous dates are read day-first.
payment_methodpayment, payment_type, payment_gateway, gatewayOrder-level, read from the order's first row. Free text, stored as written. A Shopify export's Payment Method column lands here.
paid_atpaid_date, paid_on, payment_date, date_paidOrder-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_atfulfilled_date, fulfillment_date, delivered_at, delivery_dateWhen 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.
channelchannel_name, store, locationMatched to a channel by name. Unmatched rows land in an auto-created Imported orders channel.
external_sourcesourceHalf the idempotency key.
external_idexternal_order_idThe other half. Missing, it is derived from (external_source, order_number).
descriptionproduct_name, titleWhat appears on the line.
Re-importing is safe

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:

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":"[email protected]","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:

  1. Validates that channel_id belongs to your workspace.
  2. Upserts the customer by email — or, with no email, finds them by phone number (below).
  3. Stitches visitor_token to that customer and snapshots the ad-touch trail onto the order.
  4. Resolves each line's SKU to a variant where it can.
  5. Upserts the order on (external_source, external_id).
FieldDefault / behaviour
channel_idRequired, must belong to the workspace.
customer.emailRequired unless customer.phone is sent.
customer.phoneWith 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_itemsRequired, at least one.
statusDefaults to paid, and falls back to it when the word can't be read. See Order status.
currencyDefaults to GBP.
placed_atDefaults to now. Any parseable date.
external_sourceDefaults to "external".
external_idDerived from source + order number when omitted. Set it explicitly if you want retries to be idempotent.
shipping_amountDefaults to 0.00, and is added into the computed total.
refunded_amountOptional, 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_methodOptional free text. Null when omitted.
paid_atOptional. 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.
totalComputed 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_totalComputed as quantity × unit_price unless you supply it.
line product_variant_idResolved 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:

Always send external_id

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"]
}
A patch is not an upsert

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

FieldBehaviour
statusAny of the eight values, or a word we read as one. Unknown words are refused — see Order status.
order_numberYour human-readable reference. null or an empty string clears it. Unique per workspace: 409 if another order already has it.
currency3-letter code, upper-cased for you. Anything else is 422.
subtotal, discount_amount, tax_amount, shipping_amount, totalMoney. 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_amountRead 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_atAny readable date, stored as ISO 8601. This is what the ranking window measures against.
payment_methodFree text, up to 120 characters, trimmed. null or an empty string clears it.
paid_atAny 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_idMust belong to your workspace.
customer_idMust belong to your workspace.
customer{"email": "[email protected]"} — 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_idLink 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_sourceChange 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.
metadataAn object, merged into what's there rather than replacing it. The same "only what you sent changes" rule, one level down.
line_itemsReplaces 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.

It is the idempotency key

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 sendYou 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:

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.

CodeWhen
200Updated.
401Missing or invalid secret key. It is Bearer sk_live_…, not the public key.
404No order with that external_id (from that source, if you named one). Create it with POST.
409The id matches orders from two sources — pass external_source; or the order_number you sent belongs to another order.
422No id anywhere, nothing to change, or a field we couldn't read. The message names it.
503The 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

FieldMeaning
attempt_of_order_idThe 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_idThe sale that took this basket off the table, or NULL.
basket_signatureThe 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.