Order API
Receive and manage incoming orders from buyers on the marketplace.
Order Lifecycle
Orders follow a strict state machine. The Connect API allows you to transition orders through these states:
PLACED → REJECTED
any → CANCELLED
List Orders
/connect/v1/supplier/ordersscope: orders:readList orders for your tenant. Runs in one of two modes: browse (newest first) or change feed (resumable). Pick the mode that matches what you are building.
Which mode do I need?
Browse (?cursor=<orderId>) walks backwards through history, newest first. It answers “show me the last N orders”. It is not resumable: an order created after you stored the cursor has a higher id and will never appear, and a status change on an order you already read is not re-emitted. Storing a browse cursor as a sync checkpoint silently loses orders.
Change feed (?updatedAfter=<instant>, then the returned nextCursor) walks forwards in (updatedAt, id) order. Every mutation bumps updatedAt, so a changed order re-appears ahead of your position and is delivered again. This is the mode ERP and sync integrations must use. Delivery is at-least-once — dedupe by order id + version on your side: a payment or an edit in place changes an order without changing its status.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
updatedAfter | string (ISO-8601 instant) | Start the change feed at this point in time, e.g. 1970-01-01T00:00:00Z for a full backfill. Presence of this parameter selects feed mode. |
cursor | string | Browse mode: the numeric id of the last order from the previous page. Feed mode: the opaque nextCursor from the previous page — treat it as a token, do not parse it. An opaque cursor also selects feed mode on its own, so you only need updatedAfter on the very first call. |
limit | integer | Items per page (1 — 100, default 50). |
status | string | Filter by status: PLACED, ACCEPTED, PREPARING, DISPATCHED, DELIVERED, REJECTED, CANCELLED. |
# --- Change feed: first call starts at the beginning of time ---
curl "https://api.scoperly.com/connect/v1/supplier/orders?updatedAfter=1970-01-01T00:00:00Z&limit=50" \
-H "Authorization: Bearer $TOKEN"
# Response (sanitized OrderSummaryResponse — branch / full address stripped):
# {
# "items": [
# {
# "id": 42,
# "uuid": "e3f5a2c1-...-...-...-...",
# "orderNumber": "SCP-2025-000042",
# "status": "PLACED",
# "customerKind": "BUYER_TENANT", # or INTERNAL_PARTNER - see "Three kinds of order"
# "customerName": "Café Araks",
# "buyerId": 7, # absent on an INTERNAL_PARTNER order
# "buyerName": "Café Araks",
# "saleChannel": "ORDER", # or DIRECT_SALE - a counter sale, born DELIVERED
# "supplierName": "Avan Dairy",
# "totalAmount": 1250.00, # net of VAT
# "grossTotal": 1500.00, # what the customer owes
# "amountPaid": 0.00,
# "currency": "AMD",
# "paymentStatus": "UNPAID", # UNPAID | PARTIAL | PAID | REFUNDED
# "createdAt": "2025-03-20T09:15:00Z",
# "updatedAt": "2025-03-20T09:15:00Z",
# "version": "1742461200000000" # changes with every change - dedupe on (id, version)
# }
# ],
# "nextCursor": "1742461200000000_42",
# "hasMore": true
# }
#
# A field with no value is left out, not sent as null - treat a missing key as null.
# An order for a client-book partner carries customerInternalPartnerId and
# customerInternalPartnerExternalId (your own id) instead of buyerId; an order your
# operator keyed carries placedOnBehalfByUserId; deliveredAt, cancelledAt,
# rejectedAt, cancelReason and voidReason appear once they happen.
# --- Subsequent calls: pass nextCursor back, unchanged ---
curl "https://api.scoperly.com/connect/v1/supplier/orders?cursor=1742461200000000_42&limit=50" \
-H "Authorization: Bearer $TOKEN"
# nextCursor is ALWAYS returned, including on an empty page (it echoes your own
# position back). Persist it after you have durably stored the page — never before.
# hasMore=false means "you are caught up", not "the feed ended": keep polling with
# the same cursor and new changes will arrive.
# --- Browse mode (newest first, NOT resumable) ---
curl "https://api.scoperly.com/connect/v1/supplier/orders?status=PLACED&limit=10" \
-H "Authorization: Bearer $TOKEN"Three kinds of order on the feed
Every order of yours that a customer can see is on the feed, and they do not all start the same way. Read customerKind and saleChannel before you map a row.
| Origin | How to tell | What to expect |
|---|---|---|
| Self-service order by a buyer on Scoperly | customerKind: BUYER_TENANT | Starts at PLACED. The customer is another company on the platform: its id and display name only. |
| Order your operator keyed for a client-book partner | customerKind: INTERNAL_PARTNER, no buyerId | Starts at PLACED. The customer is YOUR partner record: customerInternalPartnerExternalId on the summary and the full customer block on the detail carry your own id, tax id and contacts - join on those. It can be edited in place in the portal: the same id and status come back with new lines and totals, with no webhook - the feed carries it. |
| Counter sale recorded in the portal | saleChannel: DIRECT_SALE | First seen already DELIVERED - there is no PLACED / ACCEPTED history; its webhooks are order.delivered and, if it is annulled, order.cancelled. It can be edited in place (the same id and status with new lines and totals, no webhook - the feed carries it). An annulled sale comes back CANCELLED with voidReason; a corrected sale is annulled and recorded again as a new DELIVERED sale, and the feed does not link the two. |
Not on the feed: an order your operator keyed for a connected buyer who has not confirmed it yet (PENDING_BUYER_CONFIRMATION) and an order still inside the buyer's own approval workflow. Both appear once they become real orders.
Order Detail
/connect/v1/supplier/orders/:idscope: orders:readRetrieve full order details including sanitized line items, a reduced delivery location (city + country only), and — when the buyer matches your supplier's CRM — a nested internalPartner block (your own classification of the buyer, exposed only as externalId / name / category / route).
curl "https://api.scoperly.com/connect/v1/supplier/orders/12345" \
-H "Authorization: Bearer $TOKEN"
# Response:
# {
# "order": {
# "orderNumber": "SCP-2025-000042",
# "status": "PLACED",
# "buyerName": "Café Araks",
# "supplierName": "Avan Dairy",
# "lines": [
# {
# "id": 981,
# "productId": 5512,
# "sellerExternalId": "SKU-001",
# "productName": "Organic Green Tea 500g",
# "unitOfMeasure": "pcs",
# "quantity": 50,
# "unitPrice": 12.50,
# "vatRate": 20.00,
# "vatAmount": 125.00,
# "lineTotal": 625.00,
# "lineTotalInclVat": 750.00,
# "currency": "AMD"
# }
# ],
# # sellerExternalId is YOUR catalog id — the exact externalId you sent in the
# # catalog upsert — snapshotted when the order was placed. Join on this, not on
# # productId: it survives a later catalog re-import that reassigns external ids.
# # It is absent for products created directly in the supplier portal.
# "subtotal": 625.00,
# "deliveryFee": 0,
# "deliveryFeeSource": "AUTO_RATE",
# "vatTotal": 125.00,
# "totalAmount": 625.00,
# "amountPaid": 0.00,
# "amountDue": 750.00,
# "paymentStatus": "UNPAID",
# "saleChannel": "ORDER",
# "currency": "AMD",
# # Note: totalAmount is the NET total (subtotal + deliveryFee − discount).
# # VAT is denormalised in vatTotal for the buyer-facing breakdown.
# # Buyer pays subtotal + deliveryFee + vatTotal − discount = the gross amount.
# "deliveryCity": "Yerevan",
# "deliveryCountry": "AM",
# "requestedDeliveryDate": "2025-03-22",
# "createdAt": "2025-03-20T09:15:00Z"
# },
# "internalPartner": {
# "externalId": "CLI-0042",
# "name": "Café Araks",
# "category": "HORECA",
# "route": "Yerevan-Center-Mon"
# },
# "customer": {
# "kind": "BUYER_TENANT",
# "buyerTenantId": 7,
# "name": "Café Araks"
# }
# }
#
# "internalPartner" is your own classification of the buyer, matched by tax id
# against your client book; absent when there is no match.
#
# "customer" says who the order is for. For an order your operator keyed for a
# client-book partner it is that partner in full - YOUR data, so it comes back
# whole:
#
# "customer": {
# "kind": "INTERNAL_PARTNER",
# "internalPartnerId": 355,
# "externalId": "CLI-0042",
# "name": "Café Araks",
# "legalName": "Araks LLC",
# "taxId": "01234567",
# "address": "...", "deliveryAddress": "...",
# "contactPhone": "...", "contactEmail": "...",
# "route": "Yerevan-Center-Mon", "category": "HORECA"
# }
#
# A buyer tenant is a different party: its block carries the id and display
# name only. Fields that do not apply are omitted, not sent as null.
#
# The sanitized order omits your branch and warehouse ids, the buyer's approval
# state and the full delivery address (city and country only).Acting on the order you actually read
Every order carries a version — an opaque token that changes whenever the order does. Send it back as an If-Match header on accept, reject, dispatch or cancel, and the request is refused with 409 ORDER_VERSION_CONFLICT if the order moved in the meantime — the buyer cancelled it, or its lines were amended — instead of applying to an order you were no longer looking at.
The header is optional: omit it and behaviour is unchanged. Treat the token as opaque — compare it, do not parse it.
# Read the order, keep its version
VERSION=$(curl -s "https://api.scoperly.com/connect/v1/supplier/orders/12345" \
-H "Authorization: Bearer $TOKEN" | jq -r '.order.version')
# Act on exactly that version
curl -X POST https://api.scoperly.com/connect/v1/supplier/orders/12345/dispatch \
-H "Authorization: Bearer $TOKEN" \
-H "If-Match: $VERSION" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "trackingCode": "HP123456789AM" }'
# 409 if it changed:
# {
# "type": "ORDER_VERSION_CONFLICT",
# "status": 409,
# "detail": "The order changed since you read it. Re-read it and decide again — the current version is 1742461200000000."
# }Accept / Reject
/connect/v1/supplier/orders/:id/acceptscope: orders:writeAccept a PLACED order. Reserves stock and moves to ACCEPTED status. Supports If-Match (see Concurrency).
/connect/v1/supplier/orders/:id/rejectscope: orders:writeReject a PLACED order. Releases reserved stock. Supports If-Match (see Concurrency).
Reject Request Body
| Parameter | Type | Description |
|---|---|---|
reasonrequired | string | Reason for rejection (shown to buyer). |
# Accept an order
curl -X POST https://api.scoperly.com/connect/v1/supplier/orders/12345/accept \
-H "Authorization: Bearer sck_live_abc123..." \
-H "Idempotency-Key: $(uuidgen)"
# Reject an order
curl -X POST https://api.scoperly.com/connect/v1/supplier/orders/12345/reject \
-H "Authorization: Bearer sck_live_abc123..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "reason": "Product temporarily out of stock" }'Dispatch
/connect/v1/supplier/orders/:id/dispatchscope: orders:writeMark an ACCEPTED or PREPARING order as dispatched. Optionally include tracking information. If the order has a buyer-pending amendment (amendmentStatus = PENDING_BUYER_CONFIRMATION) the call returns 422 AMENDMENT_IN_FLIGHT — wait for the buyer to accept or reject before dispatching. Supports If-Match (see Concurrency).
Request Body (optional)
| Parameter | Type | Description |
|---|---|---|
trackingCode | string | Tracking code / waybill number from your carrier (max 200 chars). |
notes | string | Free-text notes shown to the buyer (max 1000 chars). |
curl -X POST https://api.scoperly.com/connect/v1/supplier/orders/12345/dispatch \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"trackingCode": "HP123456789AM",
"notes": "Handed to HayPost, ETA Mon evening."
}'Cancel
/connect/v1/supplier/orders/:id/cancelscope: orders:writeCancel an order you have already taken. Allowed while the order is PLACED, ACCEPTED or PREPARING — once DISPATCHED the goods are with the buyer and cancellation is a dispute, not a state transition. Stock and credit are compensated exactly as they are for a cancellation made in the supplier portal. Supports If-Match (see Concurrency).
Request Body (optional)
| Parameter | Type | Description |
|---|---|---|
reason | string | Why the order was cancelled (max 500 chars). Shown to the buyer. Defaults to "Cancelled via API". |
Use this whenever your own system cancels an order that came from the marketplace. If you skip it, the two systems diverge silently: your ERP shows the order cancelled while the buyer still sees an order being fulfilled. Returns 422 ORDER_CANNOT_CANCEL if the order has moved past PREPARING, and 422 AMENDMENT_IN_FLIGHT while a buyer amendment is awaiting confirmation.
curl -X POST https://api.scoperly.com/connect/v1/supplier/orders/12345/cancel \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "reason": "Warehouse shortage — cannot fulfil" }'Batch Operations
Both batch endpoints accept up to 50 orders per call and support Idempotency-Key. Partial failures are reported per-item with a structured errorCode (e.g. ORDER_NOT_FOUND, CONFLICT) — the overall request still returns 200 OK.
/connect/v1/supplier/orders/batch/acceptscope: orders:writeAccept multiple PLACED orders in one request (max 50). Body: { orderIds: [1, 2, ...] }.
Body:
{
"orderIds": [12345, 12346, 12347]
}Response (200 OK):
{
"succeeded": 2,
"failed": 1,
"results": [
{ "orderId": 12345, "status": "accepted" },
{ "orderId": 12346, "status": "accepted" },
{ "orderId": 12347, "status": "failed", "errorCode": "ORDER_NOT_FOUND", "error": "Order not found: 12347" }
]
}/connect/v1/supplier/orders/batch/dispatchscope: orders:writeDispatch multiple orders in one request (max 50). Each item can include its own trackingCode and notes.
Body:
{
"orders": [
{ "orderId": 12345, "trackingCode": "HP001", "notes": "Handed to HayPost" },
{ "orderId": 12346, "trackingCode": "HP002" }
]
}