Orders endpoints
The orders endpoints cover the full order lifecycle over the API: list with filters, read a single order, create a new order (with full stock decrement + webhook semantics), and update an order's status.
GET /api/v1/orders
List orders in your company.
Required scope: orders:read
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Page size, capped at 100. |
cursor | string | — | Pagination cursor. |
status | SUBMITTED / CONFIRMED / SHIPPED / DELIVERED / CANCELLED | — | Filter by status. Exact match. |
customerId | string | — | Only orders from a specific customer. |
since | ISO 8601 datetime | — | Only orders with createdAt >= since. |
until | ISO 8601 datetime | — | Only orders with createdAt <= until. |
since and until are parsed by new Date() — any format that accepts
(like 2026-04-01 or 2026-04-01T00:00:00Z) works.
Example
curl "https://distribu.app/api/v1/orders?status=SHIPPED&since=2026-04-01&limit=100" \
-H "Authorization: Bearer dk_..."
Response
{
"data": [
{
"id": "clxxorderid1...",
"status": "SHIPPED",
"poNumber": "PO-12345",
"notes": "Deliver to dock B before 11am.",
"total": 131.25,
"createdAt": "2026-04-16T14:22:00.000Z",
"updatedAt": "2026-04-17T09:10:00.000Z",
"customerId": "clxxcustomer1...",
"items": [
{
"id": "clxxitem1...",
"quantity": 10,
"unitPrice": 8.5,
"productId": "clxxproduct1...",
"productName": "Widget Blue",
"productSku": "WDG-001"
}
]
}
],
"pagination": {
"hasMore": false,
"nextCursor": null
}
}
Sorted by createdAt descending.
Notes on fields
customerId—nullfor dashboard-created orders. See Orders overview.poNumber,notes— Both nullable.items[].productName/productSku— Pulled from the product at query time (not frozen). If the product was renamed after the order was placed, the API returns the new name.items[].unitPrice— Frozen at order placement, reflecting any per-customer override that was active then.- Shipping address — Not returned by the list endpoint. See the note below under "no shipping address in the response" for details.
GET /api/v1/orders/{id}
Fetch a single order with its items.
Required scope: orders:read
Response
Same shape as one element of the list response above.
{
"data": {
"id": "clxxorderid1...",
"status": "SHIPPED",
"poNumber": "PO-12345",
"notes": "Deliver to dock B before 11am.",
"total": 131.25,
"createdAt": "2026-04-16T14:22:00.000Z",
"updatedAt": "2026-04-17T09:10:00.000Z",
"customerId": "clxxcustomer1...",
"tracking": {
"carrier": "UPS",
"number": "1Z999AA10123456784",
"url": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
},
"items": [ /* ... */ ]
}
}
The tracking field is null until the order has been marked
SHIPPED with tracking info. See
PATCH /api/v1/orders/{id} for how to attach it.
Errors
404—Order not found.— No order with that ID in your company.
Heads-up: no shipping address in the response
The shipping address is not included in the API response today — the
payload is purposely compact. To see the shipping address attached to an
order, use the dashboard (/dashboard/orders/{id}) or download the
invoice PDF, which includes the frozen snapshot. We're adding it to the
API response in a future iteration.
POST /api/v1/orders
Create a new order.
Required scope: orders:write
Request body
{
"customerId": "clxxcustomer1...",
"items": [
{ "productId": "clxxproduct1...", "quantity": 10 },
{ "productId": "clxxproduct2...", "quantity": 5 }
],
"poNumber": "PO-12345",
"notes": "Optional free-form text."
}
| Field | Type | Required | Notes |
|---|---|---|---|
customerId | string | yes | Must be a customer in your company. |
items | array | yes | Non-empty array. |
items[].productId | string | yes | Must be an active product in your company. |
items[].quantity | integer ≥ 1 | yes | Whole numbers only. |
poNumber | string | no | — |
notes | string | no | — |
Example
curl -X POST https://distribu.app/api/v1/orders \
-H "Authorization: Bearer dk_..." \
-H "Content-Type: application/json" \
-d '{
"customerId": "clxxcustomer1...",
"items": [
{ "productId": "clxxproduct1...", "quantity": 10 }
],
"poNumber": "PO-12345"
}'
Response
201 Created with the full order as { data: {...} }.
Status starts at SUBMITTED. The body mirrors GET /api/v1/orders/{id}.
What happens server-side
- Validate the request body against the schema above.
- Verify
customerIdbelongs to your company (404 if not). - In a single transaction:
- Fetch every product in
items, scoped to your company +isActive. - Reject with 400 if any product is missing or inactive:
Product "{id}" not found or is inactive. - Reject with 400 if any product has insufficient stock:
Insufficient stock for product "{name}". Available: {N}, requested: {M}. - Compute the total from each product's current catalog price
(
unitPrice × quantity). Per-customer price overrides do NOT apply to API-created orders. The API uses the catalog price only. - Decrement stock for each product.
- Create the order and its line items.
- Fetch every product in
- Fire the
order.createdwebhook. - Return
201with the new order.
API orders don't snapshot a shipping address
Unlike storefront orders (which require an address and freeze a snapshot), API orders are created without any shipping address. The order exists without shipping context — you're expected to handle fulfillment in your own system.
API orders don't send emails
No receipt to the customer. No alert to OWNER / ADMIN staff. If you want
those, either create the order through the storefront (on behalf of the
customer) or fire your own notifications on the order.created webhook.
Errors
| Status | Message |
|---|---|
400 | Invalid JSON body. |
400 | customerId is required |
400 | productId is required |
400 | quantity must be a number / quantity must be a whole number / quantity must be at least 1 |
400 | At least one item is required |
400 | Product "{id}" not found or is inactive. |
400 | Insufficient stock for product "{name}". Available: N, requested: M. |
404 | Customer not found. |
PATCH /api/v1/orders/{id}
Update an order's status, optionally attaching shipment tracking.
Required scope: orders:write
Request body
{ "status": "CONFIRMED" }
Fields:
| Field | Type | Notes |
|---|---|---|
status | string | Required. One of SUBMITTED, CONFIRMED, SHIPPED, DELIVERED, CANCELLED. |
tracking | object | Optional. Only accepted when status is SHIPPED. See below. |
tracking object (SHIPPED only)
{
"status": "SHIPPED",
"tracking": {
"carrier": "UPS",
"number": "1Z999AA10123456784"
}
}
| Field | Type | Notes |
|---|---|---|
carrier | string | Required. One of UPS, USPS, FEDEX, DHL, CANADA_POST, OTHER. |
number | string | Required. 3–64 characters. Whitespace is stripped server-side. |
url | string | Optional override URL. Required when carrier is OTHER — Distribu has no URL template to fall back to. For named carriers, omit this and Distribu will generate the right tracking link. |
When tracking is attached:
- Distribu persists
trackingCarrier,trackingNumber, andtrackingUrlon the order row. - The customer-facing order page (
/store/{slug}/orders/{id}) shows a tracking card with a Track package button. - The shipping email to the customer includes the carrier, tracking number, and tracking URL.
- The
order.status_changedwebhook payload includes atrackingsub-object.
Example
# Simple status change
curl -X PATCH https://distribu.app/api/v1/orders/clxxorderid1... \
-H "Authorization: Bearer dk_..." \
-H "Content-Type: application/json" \
-d '{ "status": "CONFIRMED" }'
# Marking as shipped with UPS tracking
curl -X PATCH https://distribu.app/api/v1/orders/clxxorderid1... \
-H "Authorization: Bearer dk_..." \
-H "Content-Type: application/json" \
-d '{
"status": "SHIPPED",
"tracking": {
"carrier": "UPS",
"number": "1Z999AA10123456784"
}
}'
# Other carrier — url is required
curl -X PATCH https://distribu.app/api/v1/orders/clxxorderid1... \
-H "Authorization: Bearer dk_..." \
-H "Content-Type: application/json" \
-d '{
"status": "SHIPPED",
"tracking": {
"carrier": "OTHER",
"number": "TRK-12345",
"url": "https://my-3pl.example.com/track/TRK-12345"
}
}'
Response
The full updated order, same shape as GET /api/v1/orders/{id},
including a tracking field (object when set, null otherwise).
Fires the order.status_changed webhook with the previousStatus and
tracking fields included.
Transition rules (API)
The API's rules are more permissive than the dashboard's. The API blocks exactly one thing:
CANCELLEDorders cannot be updated to anything else.
Response: 422 with { "error": "Cannot update a cancelled order." }.
Every other transition is allowed at the API layer — including ones the
dashboard would reject. You can, for example, move an order directly from
SUBMITTED to DELIVERED, or from DELIVERED back to CONFIRMED. The
dashboard UI enforces the strict
status workflow, but the API does not.
This is a known asymmetry. If you're writing an integration, we recommend you respect the dashboard's transition table in your own code:
| From | Allowed | |
|---|---|---|
SUBMITTED | CONFIRMED, CANCELLED | |
CONFIRMED | SHIPPED, CANCELLED | |
SHIPPED | DELIVERED | |
DELIVERED | (terminal) | |
CANCELLED | (terminal — enforced by API) |
Errors
| Status | Message |
|---|---|
400 | Invalid JSON body. |
400 | Invalid order status. — bad enum value |
400 | Tracking info is only valid when status is SHIPPED. — tracking sent with non-SHIPPED status |
400 | Tracking URL is required when carrier is OTHER. |
404 | Order not found. |
422 | Cannot update a cancelled order. |
What's not here (yet)
DELETE /api/v1/orders/{id}— hard-delete. Not planned; cancel instead.- Field updates other than
status— no API to editpoNumber,notes, line items, etc. Cancel and re-create. - Batch create — one order per request today.
That's the API. Next: Webhooks — receive events instead of polling.
