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

ParamTypeDefaultDescription
limitinteger50Page size, capped at 100.
cursorstringPagination cursor.
statusSUBMITTED / CONFIRMED / SHIPPED / DELIVERED / CANCELLEDFilter by status. Exact match.
customerIdstringOnly orders from a specific customer.
sinceISO 8601 datetimeOnly orders with createdAt >= since.
untilISO 8601 datetimeOnly 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

  • customerIdnull for 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 addressNot 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

  • 404Order 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."
}
FieldTypeRequiredNotes
customerIdstringyesMust be a customer in your company.
itemsarrayyesNon-empty array.
items[].productIdstringyesMust be an active product in your company.
items[].quantityinteger ≥ 1yesWhole numbers only.
poNumberstringno
notesstringno

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

  1. Validate the request body against the schema above.
  2. Verify customerId belongs to your company (404 if not).
  3. 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.
  4. Fire the order.created webhook.
  5. Return 201 with 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

StatusMessage
400Invalid JSON body.
400customerId is required
400productId is required
400quantity must be a number / quantity must be a whole number / quantity must be at least 1
400At least one item is required
400Product "{id}" not found or is inactive.
400Insufficient stock for product "{name}". Available: N, requested: M.
404Customer 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:

FieldTypeNotes
statusstringRequired. One of SUBMITTED, CONFIRMED, SHIPPED, DELIVERED, CANCELLED.
trackingobjectOptional. Only accepted when status is SHIPPED. See below.

tracking object (SHIPPED only)

{
  "status": "SHIPPED",
  "tracking": {
    "carrier": "UPS",
    "number": "1Z999AA10123456784"
  }
}
FieldTypeNotes
carrierstringRequired. One of UPS, USPS, FEDEX, DHL, CANADA_POST, OTHER.
numberstringRequired. 3–64 characters. Whitespace is stripped server-side.
urlstringOptional 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, and trackingUrl on 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_changed webhook payload includes a tracking sub-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:

CANCELLED orders 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:

FromAllowed
SUBMITTEDCONFIRMED, CANCELLED
CONFIRMEDSHIPPED, CANCELLED
SHIPPEDDELIVERED
DELIVERED(terminal)
CANCELLED(terminal — enforced by API)

Errors

StatusMessage
400Invalid JSON body.
400Invalid order status. — bad enum value
400Tracking info is only valid when status is SHIPPED.tracking sent with non-SHIPPED status
400Tracking URL is required when carrier is OTHER.
404Order not found.
422Cannot 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 edit poNumber, 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.