Orders, Fulfillment, Returns, Invoices
Order lifecycle & enums
status: open → processing → completed (or cancelled at any point)
payment_status: unpaid → awaiting_confirmation → paid → partially_refunded → refunded
fulfillment_status: unfulfilled → partial → fulfilled → returned
shipment status: pending → label_created → in_transit → out_for_delivery → delivered (or failed / returned)
return status: requested → approved → received → refunded (or rejected)Transitions implemented:
- Customer self-reports payment via
POST /payments/:token/mark-sent→payment_statusbecomesawaiting_confirmation(DB RPCcustomer_mark_paid). - Merchant confirms with
POST /orders/:id/confirm-payment→payment_status: paid,paid_atset, and if orderstatuswasopenit auto-advances toprocessing. If the store hasauto_issue_invoiceenabled, an invoice is issued automatically. POST /orders/:id/mark-unpaidreverts payment tounpaid(clearspaid_at, confirmer,customer_marked_paid_at).POST /orders/:id/cancelsetsstatus: cancelled, restocks items (unlessrestock:false), abandons any linked cart.- Shipment creation/update recomputes
fulfillment_statusfrom itemfulfilled_quantity/returned_quantity/quantity. When all shipments aredelivered, fulfillment isfulfilled, and payment ispaid, order auto-completes (status: completed). - Returns: creating a return doesn't change status by itself;
PATCH /returns/:idtoreceived/refundedincrementsreturned_quantityand (ifrestock) restocks; setting torefundedalso records the refund.
Finding an order
:id path params for order-scoped routes accept: UUID, numeric number, or exact order name (e.g. #1023).
Orders
GET /orders
Scope: orders:read, secret required.
Query params:
| Param | Type | Notes |
|---|---|---|
page,limit | int | pagination |
status | csv | filters status IN (...) — values: open,processing,completed,cancelled |
payment_status | csv | unpaid,awaiting_confirmation,paid,partially_refunded,refunded |
fulfillment_status | csv | unfulfilled,partial,fulfilled,returned |
currency | csv | PLN,USD,EUR |
customer_id | csv of uuid | |
source | csv | api,manual,checkout |
email | string | ILIKE exact-ish match |
created_after / created_before | ISO date | |
updated_after | ISO date |
Returns full order objects (secret serialization — includes internal_note, tags, utm, source, payment.confirmed_by), ordered newest first, with meta pagination.
GET /orders/track (no scope required — but a valid key is required)
Public order status lookup by customer. Query params: number (required, digits), email (required). Returns a single order with non-secret serialization (no internal_note/tags/utm/confirmed_by). 404 if not matched.
GET /orders/:id
Scope: orders:read, secret required. Returns {full:true, secret:true} order — includes timeline (order_events) and returns.
POST /orders
Scope: orders:write, secret required. Creates an order directly (no cart). Body:
| Field | Type | Required | Constraints |
|---|---|---|---|
currency | enum | no | PLN|USD|EUR, defaults to store default |
lines | array | yes | min 1, max 200; each { variant_id: uuid, quantity: int(1-10000) } |
email | string | yes | valid email |
phone | string|null | no | max 40 |
customer | object | no | { first_name?, last_name?, company?, accepts_marketing? } |
shipping_address | address|null | no | see address schema below |
billing_address | address|null | no | |
shipping_rate_id | uuid|null | no | |
pickup_location_id | uuid|null | no | |
discount_code | string|null | no | max 60 |
buyer_vat_id | string|null | no | max 30 |
customer_note | string|null | no | max 2000 |
internal_note | string|null | no | max 5000 |
language | enum | no | pl|en |
tags | string[] | no | max 20, each max 60 |
utm | record<string,string>|null | no | |
send_notification | bool | no | default true |
reserve_stock | bool | no | default true — decrements stock |
allow_out_of_stock | bool | no | default false — when true skips stock-check |
mark_paid | bool | no | if true, immediately confirms the payment after creation |
Address schema used throughout: { first_name?, last_name?, company?, line1?, line2?, city?, postal_code?, region?, country_code?(ISO-2, uppercased), phone? } — all nullable/optional strings with max lengths (100/100/200/200/200/100/20/100/—/40). .strict() — unknown keys rejected.
Side effects: computes a price quote, rejects with 422 checkout_invalid if there are blocking issues (variant_not_found, product_unavailable, insufficient_stock, shipping_unavailable, discount_invalid, discount_not_applicable — full issue list in details.issues). Looks up/creates a customer by email, generates payment_reference from the store's transfer-title template, decrements stock (unless reserve_stock:false), increments discount usage counters, inserts an order.created event, sends order_created notification (unless disabled), and fires customer.created/order.created automations.
Response 201: full order object ({full:true, secret:true}).
PATCH /orders/:id
Scope: orders:write, secret required. Body (all optional): status (open|processing|completed only — cannot set cancelled here, use /cancel), email, phone, shipping_address, billing_address, internal_note, customer_note, tags, buyer_vat_id. Status changes are rejected on cancelled orders with 422 order_cancelled). Returns full order.
POST /orders/:id/confirm-payment
Scope: orders:write, secret required. Body: { notify?: bool } (default true). Errors: 422 order_cancelled, 422 invalid_state if not unpaid/awaiting_confirmation. No-op (returns as-is) if already paid. Returns updated order.
POST /orders/:id/mark-unpaid
Scope: orders:write, secret required. No body. Returns updated order.
POST /payments/:token/mark-sent
Scope: checkout (usable by publishable keys — this is the customer-facing "I sent the transfer" action). :token is the order's payment_token (exposed as payment.page_url = https://your-openshopy-domain/order/<payment_token> in the order serialization). No auth beyond the key; no ownership check beyond the token itself. Response: { "data": { "payment_status": "awaiting_confirmation" } } (or unchanged status if already past that state).
POST /orders/:id/cancel
Scope: orders:write, secret required. Body: { reason?: string|null (max 500), restock?: bool (default true), notify?: bool (default true) }. No-op if already cancelled. Returns updated order.
POST /orders/:id/notes
Scope: orders:write, secret required. Body: { message: string(1-5000) }. Appends an internal timeline event (not customer-visible). Returns 201 { "ok": true }.
POST /orders/:id/notifications
Scope: orders:write, secret required. Body: { event: "order_created"|"payment_received"|"order_shipped"|"order_delivered"|"order_cancelled" }. Force-resends that notification template. Returns 201 with the notify result.
Fulfillments (shipments)
POST /orders/:id/fulfillments
Scope: orders:write, secret required.
Body:
| Field | Type | Required | Notes |
|---|---|---|---|
carrier_code | string|null | no | max 40; if given, resolves carrier_name and tracking_url from store's carriers table/template |
carrier_name | string|null | no | max 100, overrides lookup |
tracking_number | string|null | no | max 100 |
tracking_url | string|null | no | must be a URL |
status | enum | no | pending|label_created|in_transit|out_for_delivery|delivered; default in_transit |
items | array | no | [{ order_item_id: uuid, quantity: int≥1 }]; omitted = auto-fulfill all remaining quantities on every line |
notify | bool | no | default true — sends order_shipped |
Validates requested quantities against each item's remaining (quantity - fulfilled_quantity); 422 unprocessable on mismatch. Recomputes fulfillment_status; fires shipment.created and (if now fully fulfilled) order.fulfilled automations. Response 201 with the shipment row (items is the JSON array of what was shipped in this shipment).
PATCH /fulfillments/:id
Scope: orders:write, secret required. Body: { status?: pending|label_created|in_transit|out_for_delivery|delivered|failed|returned, tracking_number?, tracking_url?, carrier_code?, notify?: bool }. Setting status:"delivered" sets delivered_at, sends order_delivered (unless notify:false), and may auto-complete the order (see lifecycle above). Returns updated shipment.
DELETE /fulfillments/:id
Scope: orders:write, secret required. Reverts fulfilled_quantity on affected order items, recomputes fulfillment status, deletes the shipment row. Returns { "id", "deleted": true }.
Refunds
POST /orders/:id/refunds
Scope: orders:write, secret required. Body: { amount: int≥1 (minor units), reason?: string|null (max 500), notify?: bool }. Only allowed when payment_status is paid or partially_refunded (else 422 not_paid). amount must be between 1 and order.total - order.refunded_total (else 422 unprocessable). Sets payment_status to refunded if fully refunded else partially_refunded. Fires refund.issued automation, sends refund_issued notification. Response 201 with full order.
Returns
POST /orders/:id/returns
Scope: orders:write, secret required. Body: { items: [{ order_item_id: uuid, quantity: int≥1 }] (min 1), reason?: string|null (max 500), restock?: bool (default true), note?: string|null (max 2000) }. Validates quantities against fulfilled_quantity - returned_quantity. Computes a proportional refund_amount (capped at remaining unrefunded order total) but does not refund automatically — just creates a requested return. Response 201 with the return row.
PATCH /returns/:id
Scope: orders:write, secret required. Body: { status: requested|approved|received|refunded|rejected, refund_amount?: int|null, notify?: bool }. Moving to received or refunded (from a prior non-received/refunded state) applies returned_quantity increments and restocks (if the return's restock flag was true). Moving to refunded additionally records a refund with refund_amount (or the return's stored amount) if > 0. Returns the updated return row.
Invoices
Invoices are generated by OpenShopy. Read them here or export them for accounting from Import & export → Reports in the dashboard.
GET /invoices
Scope: invoices:read, secret required. Paginated. Filters: order_id, kind (invoice, proforma, correction, credit_note), issued_after (compares to issue_date). Returns raw invoice rows.
GET /invoices/:id
Scope: invoices:read, secret required. Returns raw invoice row or 404 not_found.
POST /orders/:id/invoices
Scope: invoices:write, secret required. Body: { kind?: "invoice"|"proforma" } (default invoice). Issues a new invoice document for the order. Response 201.
POST /invoices/:id/corrections
Scope: invoices:write, secret required. Body: { reason: string(1-500), items: [{ quantity: number≥0, unit_net: int≥0 }] }. Issues a correction document. Response 201.
POST /invoices/:id/credit-notes
Scope: invoices:write, secret required. Body: { reason: string(1-500), amount_gross: int≥1 }. Issues a credit note. Response 201.
POST /invoices/:id/cancel
Scope: invoices:write, secret required. No body. Cancels the invoice.