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 → ACCEPTED → PREPARING → DISPATCHED → DELIVERED
PLACED → REJECTED
any → CANCELLED

List Orders

GET/connect/v1/supplier/ordersscope: orders:read

List 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

ParameterTypeDescription
updatedAfterstring (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.
cursorstringBrowse 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.
limitintegerItems per page (1 — 100, default 50).
statusstringFilter 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.

OriginHow to tellWhat to expect
Self-service order by a buyer on ScoperlycustomerKind: BUYER_TENANTStarts at PLACED. The customer is another company on the platform: its id and display name only.
Order your operator keyed for a client-book partnercustomerKind: INTERNAL_PARTNER, no buyerIdStarts 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 portalsaleChannel: DIRECT_SALEFirst 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

GET/connect/v1/supplier/orders/:idscope: orders:read

Retrieve 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

POST/connect/v1/supplier/orders/:id/acceptscope: orders:write

Accept a PLACED order. Reserves stock and moves to ACCEPTED status. Supports If-Match (see Concurrency).

POST/connect/v1/supplier/orders/:id/rejectscope: orders:write

Reject a PLACED order. Releases reserved stock. Supports If-Match (see Concurrency).

Reject Request Body

ParameterTypeDescription
reasonrequiredstringReason 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

POST/connect/v1/supplier/orders/:id/dispatchscope: orders:write

Mark 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)

ParameterTypeDescription
trackingCodestringTracking code / waybill number from your carrier (max 200 chars).
notesstringFree-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

POST/connect/v1/supplier/orders/:id/cancelscope: orders:write

Cancel 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)

ParameterTypeDescription
reasonstringWhy 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.

POST/connect/v1/supplier/orders/batch/acceptscope: orders:write

Accept multiple PLACED orders in one request (max 50). Body: { orderIds: [1, 2, ...] }.

Body:

json
{
  "orderIds": [12345, 12346, 12347]
}

Response (200 OK):

json
{
  "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" }
  ]
}
POST/connect/v1/supplier/orders/batch/dispatchscope: orders:write

Dispatch multiple orders in one request (max 50). Each item can include its own trackingCode and notes.

Body:

json
{
  "orders": [
    { "orderId": 12345, "trackingCode": "HP001", "notes": "Handed to HayPost" },
    { "orderId": 12346, "trackingCode": "HP002" }
  ]
}