# Frostbyte Pro API

Version 1.5.0 · Base URL: `https://frostbytesoftware.co.nz/api/v1` · All request and response bodies are JSON.

REST API for Frostbyte Pro inventory management: products, customers, suppliers, sales orders (full lifecycle: create → confirm → dispatch → complete/cancel), purchase orders (create → submit → approve → receive), invoices and payments, stock on hand, stock adjustments, warehouses, shipments, batch/serial traceability, pricing, reference data, and reports.

Authenticate with `Authorization: Bearer fbk_...` (per-organisation API keys, read/write scopes). 120 requests/minute per key — watch the X-RateLimit-* response headers. Order, goods-receipt, payment, stock-adjustment, and dispatch POSTs accept an Idempotency-Key header for safe retries (operations carrying the parameter in this document); creates that dedupe naturally on a unique code/SKU return 409 on retry instead. All quantities are in base units; taxRate/discountPct are fractions 0–1. Human-readable reference: https://frostbytesoftware.co.nz/developers — changelog: https://frostbytesoftware.co.nz/developers/changelog

## Authentication

All endpoints require an API key, sent via either scheme:

- **bearerAuth** (HTTP bearer): API key as a bearer token: `Authorization: Bearer fbk_<48 hex chars>`. Keys are minted in Settings → API Keys with read or write scope.
- **apiKeyHeader** (header `X-API-Key`): Alternative to the Authorization header; same fbk_ token.

## Rate limits

Rate limit exceeded (rate_limit_exceeded) — 120 requests/minute per key. Honour Retry-After.

Response headers:

- `X-RateLimit-Limit` — Requests allowed per minute for this API key.
- `X-RateLimit-Remaining` — Requests remaining in the current window.
- `X-RateLimit-Reset` — Unix epoch seconds when the current window resets.
- `Retry-After` — Seconds to wait before retrying.

## Idempotency

`Idempotency-Key` header: Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h.

`Idempotency-Replayed` response header: Present (true) when this response was replayed from a previous request with the same Idempotency-Key.

Accepted on: `POST /sales-orders`, `POST /sales-orders/{id}/dispatch`, `POST /sales-orders/{id}/invoice`, `POST /invoices/{id}/payments`, `POST /purchase-orders`, `POST /purchase-orders/{id}/receipts`, `POST /stock-adjustments`, `POST /shipments/{id}/dispatch`.

## Pagination

List endpoints return `{ "data": [...], "pagination": { "total", "page", "pageSize", "totalPages" } }`.

Common query parameters:

- `page` — Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets.
- `pageSize` — Rows per page (max 200; values above the cap are clamped).
- `modifiedSince` — Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync.
- `sortDir` — Sort direction (case-insensitive).

## Error responses

Every error has the envelope `{ "error": { "code", "message", "details?" } }`.

- `error.code` — Machine-readable code: invalid_api_key, insufficient_scope, forbidden, invalid_parameter, invalid_json, validation_error, payload_too_large, not_found, conflict, conflict_in_progress, idempotency_key_reuse, invalid_reference, unprocessable, rate_limit_exceeded, service_unavailable, internal_error.
- `error.details` — Per-field issues on validation_error (max 20).

| Status | Meaning |
| --- | --- |
| `400` | Malformed request: bad query parameter (invalid_parameter), invalid JSON (invalid_json), or body failing validation (validation_error, with error.details). |
| `401` | Missing, invalid, revoked, or expired API key (invalid_api_key). |
| `403` | The key lacks the required scope (insufficient_scope) or permission (forbidden). |
| `404` | The path resource does not exist in your organisation (not_found). |
| `409` | Unique-value conflict (conflict) or a concurrent request with the same Idempotency-Key is still in flight (conflict_in_progress, with Retry-After). |
| `413` | Request body exceeds the 100 KB limit (payload_too_large). |
| `422` | Well-formed but not processable: a body-referenced id is missing from your organisation (invalid_reference), a business rule failed (unprocessable — wrong lifecycle status, insufficient stock, credit hold, over-payment), or an Idempotency-Key was reused with a different request (idempotency_key_reuse). |
| `429` | Rate limit exceeded (rate_limit_exceeded) — 120 requests/minute per key. Honour Retry-After. |
| `503` | Temporary infrastructure failure (service_unavailable) — retry after the Retry-After interval. |

## Products

Product catalogue: SKUs, pricing, tracking settings.

### GET /products — List products

Operation ID: `listProducts`

Lists the organisation's products with category, base unit, and total stock on hand. Active products only by default (includeInactive=true lifts the filter; an explicit isActive always wins). All filters are AND-combined. Default sort is name ascending; ties are broken by id so offset pagination stays stable. For incremental sync combine modifiedSince with sortBy=updatedAt&sortDir=asc.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on name, sku, or barcode. |
| `sku` | query | string | Case-insensitive EXACT match on sku — for resolving order lines by SKU, where contains-search is wrong ("PUMP-1" must not match "PUMP-10"). AND-combined with every other filter, including search. |
| `barcode` | query | string | Case-insensitive EXACT match on barcode — for scan flows. AND-combined with every other filter, including search. |
| `categoryId` | query | string | Only products in this category. |
| `productType` | query | string | Only products of this type. One of: `STOCKED`, `NON_STOCKED`, `ASSEMBLED`. |
| `isActive` | query | string | Explicit active filter; overrides includeInactive. When omitted, only active products are returned unless includeInactive=true. One of: `true`, `false`. |
| `includeInactive` | query | string | Pass true to lift the default active-only filter (returns active AND inactive). Ignored when isActive is set. One of: `true`. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |
| `sortBy` | query | string | Sort field. One of: `name`, `sku`, `createdAt`, `updatedAt`. Default: "name". |
| `sortDir` | query | string | Sort direction (case-insensitive). One of: `asc`, `desc`. |

**Responses:** `200` — Paginated product list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### POST /products — Create a product

Operation ID: `createProduct`

Creates a product (write scope). Only sku and name are required; productType defaults to STOCKED, defaultPrice/costPrice/taxRate to 0, and the tracking flags to false. taxRate is a FRACTION 0–1 (0.15 = 15%). taxRate, reorderQuantity, and notes are the API's named fields for metadata-resident values — a raw `metadata` body key is rejected with 400. When purchaseUomId (or saleUomId) is set, its base-unit factor unitsPerPurchaseUom (unitsPerSaleUom) is required and the unit must differ from the base unitOfMeasureId (422 unprocessable otherwise). Body-referenced ids (categoryId, unitOfMeasureId, purchaseUomId, saleUomId, preferredSupplierId) must exist in your organisation (422 invalid_reference). A duplicate SKU (org-unique) or a case-insensitively duplicate NAME returns 409. Returns 201 with the detail shape.

**Request body** — `application/json` (required).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sku` | string | yes | Min length 1. Max length 50. |
| `name` | string | yes | Min length 1. Max length 255. |
| `description` | string \| null | no | Max length 5000. |
| `categoryId` | string (cuid) \| null | no | – |
| `unitOfMeasureId` | string (cuid) \| null | no | – |
| `purchaseUomId` | string (cuid) \| null | no | – |
| `unitsPerPurchaseUom` | number \| null | no | Max 1000000. Must be > 0. |
| `saleUomId` | string (cuid) \| null | no | – |
| `unitsPerSaleUom` | number \| null | no | Max 1000000. Must be > 0. |
| `productType` | string | no | One of: `STOCKED`, `NON_STOCKED`, `ASSEMBLED`. Default: "STOCKED". |
| `isBomProduct` | boolean | no | Default: false. |
| `isBatchTracked` | boolean | no | Default: false. |
| `requiresSerialTracking` | boolean | no | Default: false. |
| `requiresInspection` | boolean | no | Default: false. |
| `preferredSupplierId` | string (cuid) \| null | no | – |
| `defaultPrice` | number | no | Default: 0. Min 0. Max 99999999.9999. |
| `costPrice` | number | no | Default: 0. Min 0. Max 99999999.9999. |
| `taxRate` | number | no | Default: 0. Min 0. Max 1. |
| `weight` | number \| null | no | Min 0. Max 99999999. |
| `barcode` | string \| null | no | Max length 100. |
| `minStockLevel` | number \| null | no | Min 0. Max 99999999. |
| `maxStockLevel` | number \| null | no | Min 0. Max 99999999. |
| `reorderQuantity` | integer \| null | no | Min 0. Max 99999999. |
| `notes` | string \| null | no | Max length 2000. |
| `imageUrl` | string \| null | no | Max length 2048. |

**Responses:** `201` — Created product (detail shape). · Errors: `400`, `401`, `403`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /products/{id} — Get a product

Operation ID: `getProduct`

Fetches one product in the detail shape: the list fields plus purchase/sale units with base-unit factors, variants, supplier links, per-warehouse stock levels, reorderQuantity, and notes.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Product id. |

**Responses:** `200` — Product detail. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### PUT /products/{id} — Update a product

Operation ID: `updateProduct`

Partial (merge-patch) update, write scope: ONLY the keys present in the request body are applied — omitted fields keep their current values (schema defaults are NOT re-applied to absent keys), and an explicit null clears a nullable field. taxRate / reorderQuantity / notes are merged into the product's metadata individually; a raw `metadata` body key is rejected with 400. Duplicate-SKU and duplicate-name checks run only when those fields change (409). Purchase/sale unit coherence is enforced against the EFFECTIVE values (current row merged with the patch): a set purchaseUomId/saleUomId needs its units-per factor and must differ from the base unitOfMeasureId (422). Setting isActive=false is blocked with 409 while the product has sales- or purchase-order lines on non-completed, non-cancelled orders. Changing preferredSupplierId also reconciles the supplier links' isPreferred flags. Returns the updated detail shape.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Product id. |

**Request body** — `application/json` (required).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sku` | string | no | Min length 1. Max length 50. |
| `name` | string | no | Min length 1. Max length 255. |
| `description` | string \| null | no | Max length 5000. |
| `categoryId` | string (cuid) \| null | no | – |
| `unitOfMeasureId` | string (cuid) \| null | no | – |
| `purchaseUomId` | string (cuid) \| null | no | – |
| `unitsPerPurchaseUom` | number \| null | no | Max 1000000. Must be > 0. |
| `saleUomId` | string (cuid) \| null | no | – |
| `unitsPerSaleUom` | number \| null | no | Max 1000000. Must be > 0. |
| `productType` | string | no | One of: `STOCKED`, `NON_STOCKED`, `ASSEMBLED`. Default: "STOCKED". |
| `isBomProduct` | boolean | no | Default: false. |
| `isBatchTracked` | boolean | no | Default: false. |
| `requiresSerialTracking` | boolean | no | Default: false. |
| `requiresInspection` | boolean | no | Default: false. |
| `preferredSupplierId` | string (cuid) \| null | no | – |
| `defaultPrice` | number | no | Default: 0. Min 0. Max 99999999.9999. |
| `costPrice` | number | no | Default: 0. Min 0. Max 99999999.9999. |
| `taxRate` | number | no | Default: 0. Min 0. Max 1. |
| `weight` | number \| null | no | Min 0. Max 99999999. |
| `barcode` | string \| null | no | Max length 100. |
| `minStockLevel` | number \| null | no | Min 0. Max 99999999. |
| `maxStockLevel` | number \| null | no | Min 0. Max 99999999. |
| `reorderQuantity` | integer \| null | no | Min 0. Max 99999999. |
| `notes` | string \| null | no | Max length 2000. |
| `imageUrl` | string \| null | no | Max length 2048. |
| `isActive` | boolean | no | – |

**Responses:** `200` — Updated product (detail shape). · Errors: `400`, `401`, `403`, `404`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

## Customers

Customer records, contacts, addresses, credit.

### GET /customers — List customers

Operation ID: `listCustomers`

Lists the organisation's customers (header shape — no contacts/addresses; fetch a single customer for those). Active customers only unless includeInactive is set. Requires the sales.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on name, code, or email. |
| `code` | query | string | Exact customer-code match (case-sensitive). |
| `includeInactive` | query | string | Pass true (or 1) to include deactivated customers. One of: `true`, `1`. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |
| `sortBy` | query | string | Sort field (whitelist; anything else is a 400). One of: `name`, `code`, `createdAt`, `updatedAt`. Default: "name". |
| `sortDir` | query | string | Sort direction (case-insensitive). One of: `asc`, `desc`. |

**Responses:** `200` — Paginated customer list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### POST /customers — Create a customer

Operation ID: `createCustomer`

Creates a customer, optionally with nested contacts and addresses in the same request. Requires the sales.create permission; the credit fields (creditLimit, creditHold, creditHoldReason, discountPct, taxExempt, taxExemptReason) additionally require customers.manage_credit — sending any of them without it is a 403, never a silent drop.

Nested-children rules: at most 50 contacts and 50 addresses; at most one contact may set isPrimary (none flagged → the first is primary); at most one address each may set isDefaultShipping / isDefaultBilling (none flagged → the first address becomes the default for both). When contacts/addresses are omitted, a primary contact and a default address are materialised from the scalar email/phone/address fields; when supplied, the children win and the scalar fields are recomputed from them.

References are validated against your organisation: priceTierId, customerGroupId, salesRepId (must be an active member) and parentCustomerId (must be an active head office without a parent of its own) → 422 invalid_reference when missing. isHeadOffice and parentCustomerId are mutually exclusive (422 unprocessable). A duplicate code is a 409.

**Request body** — `application/json` (required). Customer to create. Most optional string fields accept null or "" for 'no value', but email accepts only "" or omission — explicit null fails validation (400). discountPct is a fraction 0–1; creditLimit null/omitted = unlimited.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | yes | Min length 1. Max length 20. Pattern: `^[A-Za-z0-9_-]+$`. |
| `name` | string | yes | Min length 1. Max length 200. |
| `email` | string (email) \| string | no | – |
| `phone` | string \| null \| string | no | – |
| `address` | string \| null \| string | no | – |
| `region` | string \| null \| string | no | – |
| `suburb` | string \| null \| string | no | – |
| `postcode` | string \| null \| string | no | – |
| `location` | string \| null \| string | no | – |
| `currency` | string | no | One of: `NZD`, `AUD`, `USD`, `GBP`, `EUR`, `CAD`, `SGD`, `JPY`. Default: "NZD". |
| `taxNumber` | string \| null \| string | no | – |
| `notes` | string \| null \| string | no | – |
| `priceTierId` | string (cuid) \| null | no | – |
| `paymentTermsDays` | integer \| null | no | Min 0. Max 365. |
| `isHeadOffice` | boolean | no | Default: false. |
| `parentCustomerId` | string (cuid) \| null | no | – |
| `customerGroupId` | string (cuid) \| null | no | – |
| `salesRepId` | string (cuid) \| null | no | – |
| `creditLimit` | number \| null | no | Min 0. Max 1000000000. |
| `creditHold` | boolean | no | Default: false. |
| `creditHoldReason` | string \| null \| string | no | – |
| `discountPct` | number \| null | no | Min 0. Max 1. |
| `taxExempt` | boolean | no | Default: false. |
| `taxExemptReason` | string \| null \| string | no | – |
| `contacts` | array of object | no | Max 50 items. |
| `contacts[].name` | string | yes | Min length 1. Max length 200. |
| `contacts[].role` | string \| null \| string | no | – |
| `contacts[].email` | string (email) \| string | no | – |
| `contacts[].phone` | string \| null \| string | no | – |
| `contacts[].mobile` | string \| null \| string | no | – |
| `contacts[].isPrimary` | boolean | no | Default: false. |
| `addresses` | array of object | no | Max 50 items. |
| `addresses[].label` | string \| null \| string | no | – |
| `addresses[].type` | string | no | One of: `SHIPPING`, `BILLING`, `BOTH`. Default: "BOTH". |
| `addresses[].address` | string \| null \| string | no | – |
| `addresses[].city` | string \| null \| string | no | – |
| `addresses[].region` | string \| null \| string | no | – |
| `addresses[].suburb` | string \| null \| string | no | – |
| `addresses[].postcode` | string \| null \| string | no | – |
| `addresses[].country` | string \| null \| string | no | – |
| `addresses[].isDefaultShipping` | boolean | no | Default: false. |
| `addresses[].isDefaultBilling` | boolean | no | Default: false. |

**Responses:** `201` — Created customer (detail shape). · Errors: `400`, `401`, `403`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /customers/{id} — Get a customer

Operation ID: `getCustomer`

Customer detail: header fields plus contacts (primary first), addresses (defaults first), the parentCustomer head-office embed, and computed outstandingBalance / availableCredit. Requires the sales.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |

**Responses:** `200` — Customer detail. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### PUT /customers/{id} — Update a customer

Operation ID: `updateCustomer`

Updates scalar fields with merge-patch semantics despite the PUT verb: only keys present in the body change; explicit null or "" clears a field (exception: email only clears via "" — explicit null fails validation). Nested contacts/addresses are NOT updatable via this endpoint in v1 — contacts/addresses keys in the body are ignored. Editing the scalar email/phone/address fields propagates to the primary contact and default address (created if absent).

Requires the sales.edit permission; credit fields additionally require customers.manage_credit (403 if present without it). Changing code re-checks uniqueness (409 on duplicate); un-marking isHeadOffice while subsidiaries still link here is a 409; setting isHeadOffice and parentCustomerId together (against the post-update effective state) is a 422; body references are validated like create (422 invalid_reference).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |

**Request body** — `application/json` (required). Sparse patch — every field optional; omitted fields are left unchanged.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | no | Min length 1. Max length 20. Pattern: `^[A-Za-z0-9_-]+$`. |
| `name` | string | no | Min length 1. Max length 200. |
| `email` | string (email) \| string | no | – |
| `phone` | string \| null \| string | no | – |
| `address` | string \| null \| string | no | – |
| `region` | string \| null \| string | no | – |
| `suburb` | string \| null \| string | no | – |
| `postcode` | string \| null \| string | no | – |
| `location` | string \| null \| string | no | – |
| `currency` | string | no | One of: `NZD`, `AUD`, `USD`, `GBP`, `EUR`, `CAD`, `SGD`, `JPY`. Default: "NZD". |
| `taxNumber` | string \| null \| string | no | – |
| `notes` | string \| null \| string | no | – |
| `priceTierId` | string (cuid) \| null | no | – |
| `paymentTermsDays` | integer \| null | no | Min 0. Max 365. |
| `isHeadOffice` | boolean | no | Default: false. |
| `parentCustomerId` | string (cuid) \| null | no | – |
| `customerGroupId` | string (cuid) \| null | no | – |
| `salesRepId` | string (cuid) \| null | no | – |
| `creditLimit` | number \| null | no | Min 0. Max 1000000000. |
| `creditHold` | boolean | no | Default: false. |
| `creditHoldReason` | string \| null \| string | no | – |
| `discountPct` | number \| null | no | Min 0. Max 1. |
| `taxExempt` | boolean | no | Default: false. |
| `taxExemptReason` | string \| null \| string | no | – |

**Responses:** `200` — Updated customer (detail shape). · Errors: `400`, `401`, `403`, `404`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /customers/{id}/contacts — List a customer's contacts

Operation ID: `listCustomerContacts`

Lists the customer's contacts (primary first, then oldest first). Requires the sales.view permission; the customer must exist in your organisation (404 otherwise).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |

**Responses:** `200` — Paginated contact list. · Errors: `400`, `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### POST /customers/{id}/contacts — Add a contact to a customer

Operation ID: `createCustomerContact`

Adds a contact. The first contact is always primary; sending isPrimary true on a later contact unsets isPrimary on the others (single-primary invariant). When the new contact becomes primary, the customer's scalar email/phone are re-derived from it. Requires the sales.edit permission. The parent customer must exist in your organisation (404 otherwise). Mutations re-sync the customer's scalar email/phone/address mirror so the header stays consistent; the response is the refreshed customer detail.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |

**Request body** — `application/json` (required). Contact to add. isPrimary defaults to false but is forced true for the customer's first contact.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Min length 1. Max length 200. |
| `role` | string \| null \| string | no | – |
| `email` | string (email) \| string | no | – |
| `phone` | string \| null \| string | no | – |
| `mobile` | string \| null \| string | no | – |
| `isPrimary` | boolean | no | Default: false. |

**Responses:** `201` — Customer detail after the contact was added. · Errors: `400`, `401`, `403`, `404`, `413`, `429`, `503` — see [Error responses](#error-responses).

### PUT /customers/{id}/contacts/{contactId} — Update a customer contact

Operation ID: `updateCustomerContact`

Full-resource update of one contact (every field is replaced; omitting an optional field clears it). Setting isPrimary true unsets it on the other contacts. The contact must belong to the path customer (404 otherwise). Requires the sales.edit permission. The parent customer must exist in your organisation (404 otherwise). Mutations re-sync the customer's scalar email/phone/address mirror so the header stays consistent; the response is the refreshed customer detail.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |
| `contactId` | path | string | **Required.** Contact id (cuid). Must belong to the path customer. |

**Request body** — `application/json` (required). Replacement contact fields.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Min length 1. Max length 200. |
| `role` | string \| null \| string | no | – |
| `email` | string (email) \| string | no | – |
| `phone` | string \| null \| string | no | – |
| `mobile` | string \| null \| string | no | – |
| `isPrimary` | boolean | no | Default: false. |

**Responses:** `200` — Customer detail after the contact was updated. · Errors: `400`, `401`, `403`, `404`, `413`, `429`, `503` — see [Error responses](#error-responses).

### DELETE /customers/{id}/contacts/{contactId} — Delete a customer contact

Operation ID: `deleteCustomerContact`

Removes one contact. If it was the primary contact, the oldest remaining contact is promoted to primary; deleting the customer's last contact is allowed (the scalar email/phone are left untouched, never wiped). The contact must belong to the path customer (404 otherwise). Requires the sales.edit permission. The parent customer must exist in your organisation (404 otherwise). Mutations re-sync the customer's scalar email/phone/address mirror so the header stays consistent; the response is the refreshed customer detail.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |
| `contactId` | path | string | **Required.** Contact id (cuid). Must belong to the path customer. |

**Responses:** `200` — Customer detail after the contact was removed. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### GET /customers/{id}/addresses — List a customer's addresses

Operation ID: `listCustomerAddresses`

Lists the customer's addresses (default billing first, then default shipping, then oldest first). Requires the sales.view permission; the customer must exist in your organisation (404 otherwise).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |

**Responses:** `200` — Paginated address list. · Errors: `400`, `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### POST /customers/{id}/addresses — Add an address to a customer

Operation ID: `createCustomerAddress`

Adds an address. The first address becomes the default for both shipping and billing; sending isDefaultShipping/isDefaultBilling true on a later address unsets that dimension's default on the others (the two dimensions are resolved independently). When defaults change, the customer's scalar address/region/suburb/postcode/location mirror is re-derived (city maps to the scalar location). Requires the sales.edit permission. The parent customer must exist in your organisation (404 otherwise). Mutations re-sync the customer's scalar email/phone/address mirror so the header stays consistent; the response is the refreshed customer detail.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |

**Request body** — `application/json` (required). Address to add. The default flags default to false but are forced true for the customer's first address.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | string \| null \| string | no | – |
| `type` | string | no | One of: `SHIPPING`, `BILLING`, `BOTH`. Default: "BOTH". |
| `address` | string \| null \| string | no | – |
| `city` | string \| null \| string | no | – |
| `region` | string \| null \| string | no | – |
| `suburb` | string \| null \| string | no | – |
| `postcode` | string \| null \| string | no | – |
| `country` | string \| null \| string | no | – |
| `isDefaultShipping` | boolean | no | Default: false. |
| `isDefaultBilling` | boolean | no | Default: false. |

**Responses:** `201` — Customer detail after the address was added. · Errors: `400`, `401`, `403`, `404`, `413`, `429`, `503` — see [Error responses](#error-responses).

### PUT /customers/{id}/addresses/{addressId} — Update a customer address

Operation ID: `updateCustomerAddress`

Full-resource update of one address (every field is replaced; omitting an optional field clears it). Setting isDefaultShipping/isDefaultBilling true unsets that dimension's default on the other addresses. The address must belong to the path customer (404 otherwise). Requires the sales.edit permission. The parent customer must exist in your organisation (404 otherwise). Mutations re-sync the customer's scalar email/phone/address mirror so the header stays consistent; the response is the refreshed customer detail.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |
| `addressId` | path | string | **Required.** Address id (cuid). Must belong to the path customer. |

**Request body** — `application/json` (required). Replacement address fields.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | string \| null \| string | no | – |
| `type` | string | no | One of: `SHIPPING`, `BILLING`, `BOTH`. Default: "BOTH". |
| `address` | string \| null \| string | no | – |
| `city` | string \| null \| string | no | – |
| `region` | string \| null \| string | no | – |
| `suburb` | string \| null \| string | no | – |
| `postcode` | string \| null \| string | no | – |
| `country` | string \| null \| string | no | – |
| `isDefaultShipping` | boolean | no | Default: false. |
| `isDefaultBilling` | boolean | no | Default: false. |

**Responses:** `200` — Customer detail after the address was updated. · Errors: `400`, `401`, `403`, `404`, `413`, `429`, `503` — see [Error responses](#error-responses).

### DELETE /customers/{id}/addresses/{addressId} — Delete a customer address

Operation ID: `deleteCustomerAddress`

Removes one address. Whichever default(s) it held are promoted to the oldest type-compatible remaining address per dimension (a billing-only address never inherits the shipping default and vice-versa); deleting the customer's last address is allowed. The address must belong to the path customer (404 otherwise). Requires the sales.edit permission. The parent customer must exist in your organisation (404 otherwise). Mutations re-sync the customer's scalar email/phone/address mirror so the header stays consistent; the response is the refreshed customer detail.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Customer id (cuid). |
| `addressId` | path | string | **Required.** Address id (cuid). Must belong to the path customer. |

**Responses:** `200` — Customer detail after the address was removed. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

## Suppliers

Supplier records, locations, price lists.

### GET /suppliers — List suppliers

Operation ID: `listSuppliers`

Lists the organisation's suppliers (header shape — no locations/price list; fetch a single supplier for those). Active suppliers only unless includeInactive is set. Requires the purchasing.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on name, code, or email. |
| `code` | query | string | Exact supplier-code match (case-sensitive). |
| `includeInactive` | query | string | Pass true (or 1) to include deactivated suppliers. One of: `true`, `1`. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |
| `sortBy` | query | string | Sort field (whitelist; anything else is a 400). One of: `name`, `code`, `createdAt`, `updatedAt`. Default: "name". |
| `sortDir` | query | string | Sort direction (case-insensitive). One of: `asc`, `desc`. |

**Responses:** `200` — Paginated supplier list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### POST /suppliers — Create a supplier

Operation ID: `createSupplier`

Creates a supplier. Requires the purchasing.create permission. code is required and client-supplied (^[A-Za-z0-9_-]+$, max 20 — suppliers do not use sequence auto-numbering); a duplicate code is a 409. Locations and price-list rows are not creatable via this endpoint in v1.

**Request body** — `application/json` (required). Supplier to create. Optional string fields accept null or "" for 'no value' (email and salesContactEmail accept null too — it is coerced to "").

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | yes | Min length 1. Max length 20. Pattern: `^[A-Za-z0-9_-]+$`. |
| `name` | string | yes | Min length 1. Max length 200. |
| `email` | string (email) \| string | no | – |
| `phone` | string \| null \| string | no | – |
| `address` | string \| null \| string | no | – |
| `postcode` | string \| null \| string | no | – |
| `currency` | string | no | One of: `NZD`, `AUD`, `USD`, `GBP`, `EUR`, `CAD`, `SGD`, `JPY`. Default: "NZD". |
| `department` | string \| null \| string | no | – |
| `taxNumber` | string \| null \| string | no | – |
| `salesContactName` | string \| null \| string | no | – |
| `salesContactEmail` | string (email) \| string | no | – |
| `salesContactPhone` | string \| null \| string | no | – |
| `notes` | string \| null \| string | no | – |

**Responses:** `201` — Created supplier (detail shape). · Errors: `400`, `401`, `403`, `409`, `413`, `429`, `503` — see [Error responses](#error-responses).

### GET /suppliers/{id} — Get a supplier

Operation ID: `getSupplier`

Supplier detail: header fields plus locations (defaults first) and the per-supplier price list (preferred first, then cheapest; capped at 500 rows). Requires the purchasing.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Supplier id (cuid). |

**Responses:** `200` — Supplier detail. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### PUT /suppliers/{id} — Update a supplier

Operation ID: `updateSupplier`

Updates scalar fields with merge-patch semantics despite the PUT verb: only keys present in the body change; explicit null or "" clears a field. Locations and price-list rows are not updatable via this endpoint in v1. Requires the purchasing.edit permission. Changing code re-checks uniqueness (409 on duplicate).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Supplier id (cuid). |

**Request body** — `application/json` (required). Sparse patch — every field optional; omitted fields are left unchanged.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | no | Min length 1. Max length 20. Pattern: `^[A-Za-z0-9_-]+$`. |
| `name` | string | no | Min length 1. Max length 200. |
| `email` | string (email) \| string | no | – |
| `phone` | string \| null \| string | no | – |
| `address` | string \| null \| string | no | – |
| `postcode` | string \| null \| string | no | – |
| `currency` | string | no | One of: `NZD`, `AUD`, `USD`, `GBP`, `EUR`, `CAD`, `SGD`, `JPY`. Default: "NZD". |
| `department` | string \| null \| string | no | – |
| `taxNumber` | string \| null \| string | no | – |
| `salesContactName` | string \| null \| string | no | – |
| `salesContactEmail` | string (email) \| string | no | – |
| `salesContactPhone` | string \| null \| string | no | – |
| `notes` | string \| null \| string | no | – |

**Responses:** `200` — Updated supplier (detail shape). · Errors: `400`, `401`, `403`, `404`, `409`, `413`, `429`, `503` — see [Error responses](#error-responses).

## Sales Orders

Sales orders through their full lifecycle.

### GET /sales-orders — List sales orders

Operation ID: `listSalesOrders`

Lists sales orders with customer summary and line count. Default sort is orderDate descending (id tiebreaker keeps pagination stable). Requires the sales.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on orderNumber or customer name. |
| `status` | query | string | Comma-separated list of statuses to include (e.g. status=CONFIRMED,PICKING,PACKED). Any value outside the enum is a 400 invalid_parameter. |
| `customerId` | query | string | Exact match on the customer id. |
| `dateFrom` | query | string (date-time) | Include orders with orderDate at or after this ISO 8601 date or timestamp. |
| `dateTo` | query | string (date-time) | Include orders with orderDate at or before this ISO 8601 date or timestamp. A date-only value (YYYY-MM-DD) is extended to end-of-day UTC (23:59:59.999Z). |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |
| `sortBy` | query | string | Sort field. One of: `orderDate`, `orderNumber`, `totalAmount`, `createdAt`, `updatedAt`. Default: "orderDate". |
| `sortDir` | query | string | Sort direction (case-insensitive). Defaults to desc. One of: `asc`, `desc`. Default: "desc". |

**Responses:** `200` — Paginated sales-order list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### POST /sales-orders — Create a sales order (DRAFT)

Operation ID: `createSalesOrder`

Creates a DRAFT sales order — side-effect-free on stock and credit (reservation and credit enforcement happen at confirm). unitPrice is REQUIRED on every line: the API performs no price or tier resolution. quantity/unitPrice are in BASE units; taxRate/discountPct are fractions 0–1. Server-side overrides to be aware of: a tax-exempt customer forces ALL line/charge taxRate to 0; when discountPct is omitted the customer's standing discount is auto-applied; when assignedToId is omitted it defaults to the customer's sales rep. Totals are always computed server-side — caller-supplied totals are never accepted — and orderNumber comes from the org's SO sequence. Referenced records must belong to your organisation and be active (customer, warehouse, line products, assigned user) and addresses must belong to the customer — violations are 422 invalid_reference. Because creating consumes a sequence number, send an Idempotency-Key: a timed-out retry with the same key and body replays the stored 201 (Idempotency-Replayed: true) instead of double-creating. Requires the sales.create permission and a write-scope key.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | header | string | Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h. Max length 200. |

**Request body** — `application/json` (required).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `customerId` | string (cuid) | yes | – |
| `requiredDate` | string (date-time) \| null | no | – |
| `notes` | string \| null | no | Max length 2000. |
| `warehouseId` | string (cuid) \| null | no | – |
| `assignedToId` | string (cuid) \| null | no | – |
| `discountPct` | number \| null | no | Min 0. Max 1. |
| `shipToAddressId` | string (cuid) \| null | no | – |
| `billToAddressId` | string (cuid) \| null | no | – |
| `lines` | array of object | yes | Min 1 item(s). Max 100 items. |
| `lines[].productId` | string (cuid) | yes | – |
| `lines[].quantity` | number | yes | Max 999999. Must be > 0. |
| `lines[].unitPrice` | number | yes | Min 0. Max 99999999. |
| `lines[].taxRate` | number | no | Default: 0. Min 0. Max 1. |
| `lines[].discountPct` | number \| null | no | Min 0. Max 1. |
| `lines[].saleUomFactor` | number \| null | no | Must be > 0. |
| `lines[].saleUomAbbrev` | string \| null | no | Max length 50. |
| `charges` | array of object | no | Default: []. Max 20 items. |
| `charges[].chargeType` | string | yes | One of: `FREIGHT`, `HANDLING`, `INSURANCE`, `SURCHARGE`, `OTHER`. |
| `charges[].description` | string | yes | Min length 1. Max length 500. |
| `charges[].amount` | number | yes | Min 0. Max 99999999. |
| `charges[].taxRate` | number | no | Default: 0. Min 0. Max 1. |

**Responses:** `201` — The created sales order (full detail shape). · Errors: `400`, `401`, `403`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /sales-orders/{id} — Get a sales order

Operation ID: `getSalesOrder`

Fetches one sales order with lines (sorted by sortOrder), charges, and ship-to/bill-to addresses. Requires the sales.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Sales order id. |

**Responses:** `200` — The sales order. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### POST /sales-orders/{id}/confirm — Confirm a sales order

Operation ID: `confirmSalesOrder`

DRAFT → CONFIRMED. Takes no request body (any body sent is ignored). Reserves stock for every line in the order's warehouse and enforces credit: a customer on credit hold ALWAYS blocks; exceeding the credit limit blocks only when the organisation's credit enforcement mode is BLOCK. Confirmation requires a warehouse on the order and sufficient unreserved stock (aggregated across bins) for every line — failures are 422 unprocessable with the product and quantities named. Calling it on a non-DRAFT order is a 422 naming the current status, so plain retries are safe. Requires the sales.approve permission and a write-scope key.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Sales order id. |

**Responses:** `200` — The confirmed sales order. · Errors: `401`, `403`, `404`, `422`, `429`, `503` — see [Error responses](#error-responses).

### POST /sales-orders/{id}/dispatch — Dispatch a sales order (full or partial wave)

Operation ID: `dispatchSalesOrder`

PACKED | PARTIALLY_DISPATCHED → DISPATCHED or PARTIALLY_DISPATCHED. The body is OPTIONAL: omit it (or `lines`, or send an EMPTY `lines` array — empty is NOT "dispatch nothing") to dispatch everything packed-but-not-yet-dispatched; pass per-line { soLineId, quantity } overrides for a partial wave. Each quantity is CLAMPED to that line's packed-not-yet-dispatched balance (negatives to 0); a soLineId from another order is a 422 invalid_reference, and a call where nothing ends up dispatchable is a 422. When every line reaches its ordered quantity the order becomes DISPATCHED, otherwise PARTIALLY_DISPATCHED and it can be dispatched again in a later wave. Stock was already deducted at picking — dispatch advances dispatchedQty, stamps shippedDate on the first dispatch, and transitions the dispatched lines' ALLOCATED serial numbers to SOLD. Customer notifications and e-commerce fulfilment pushes (Shopify/WooCommerce/BigCommerce) fire only on FULL dispatch. Wrong-status calls are a 422 naming the current status. Because a partial dispatch leaves the order re-dispatchable, a timed-out retry without an Idempotency-Key could dispatch the same wave twice — send one: a retry with the same key and body replays the stored response (Idempotency-Replayed: true) instead of re-executing. Requires the sales.dispatch permission and a write-scope key.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Sales order id. |
| `Idempotency-Key` | header | string | Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h. Max length 200. |

**Request body** — `application/json` (optional). Optional. Omit entirely for a full dispatch; supply `lines` for a partial wave.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `lines` | array of object | no | – |
| `lines[].soLineId` | string | yes | Min length 1. |
| `lines[].quantity` | number | yes | Min 0. Max 99999999. |

**Responses:** `200` — The sales order after dispatch (status DISPATCHED or PARTIALLY_DISPATCHED). · Errors: `400`, `401`, `403`, `404`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### POST /sales-orders/{id}/complete — Complete a sales order

Operation ID: `completeSalesOrder`

DISPATCHED → COMPLETED. Takes no request body (any body sent is ignored). Every line must be FULLY invoiced (non-VOID invoices, aggregated per line) before completion — an under-invoiced line is a 422 naming the product and its invoiced vs ordered quantities. Calling it on a non-DISPATCHED order is a 422 naming the current status. Requires the sales.approve permission and a write-scope key.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Sales order id. |

**Responses:** `200` — The completed sales order. · Errors: `401`, `403`, `404`, `422`, `429`, `503` — see [Error responses](#error-responses).

### POST /sales-orders/{id}/cancel — Cancel a sales order

Operation ID: `cancelSalesOrder`

DRAFT | CONFIRMED | PICKING | PACKED → CANCELLED. The body is OPTIONAL: { reason } (≤500 chars) is stored as the cancellation reason. Stock reversal matches how far the order progressed: CONFIRMED releases reservations only; PICKING/PACKED returns the picked quantity to stock and releases the unpicked reservation balance; ALLOCATED serial numbers go back to AVAILABLE and pickedQty/packedQty reset. Cancellation is blocked (422 unprocessable) when any line has shipped units (dispatchedQty > 0 — raise a customer return instead), when active (non-VOID) invoices exist (void them first), when active customer returns exist, or when the order is in any other status (the current status is named). Requires the sales.edit permission and a write-scope key.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Sales order id. |

**Request body** — `application/json` (optional). Optional cancellation reason.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | no | Max length 500. |

**Responses:** `200` — The cancelled sales order. · Errors: `400`, `401`, `403`, `404`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

## Purchase Orders

Purchase orders, approval flow, goods receipts.

### GET /purchase-orders — List purchase orders

Operation ID: `listPurchaseOrders`

Lists the organisation's purchase orders (summary shape — fetch /purchase-orders/{id} for lines and receipts). Default order is orderDate descending with an id tiebreaker for stable pagination.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on orderNumber or supplier name. |
| `status` | query | string | Comma-separated list of statuses to include (case-insensitive). Allowed values: DRAFT, SUBMITTED, APPROVED, PARTIALLY_RECEIVED, RECEIVED, COMPLETED, CANCELLED. An unknown value is rejected with 400 invalid_parameter. |
| `supplierId` | query | string | Return only purchase orders for this supplier id. |
| `dateFrom` | query | string | Return only orders with orderDate at or after this ISO 8601 date (YYYY-MM-DD) or timestamp. |
| `dateTo` | query | string | Return only orders with orderDate at or before this ISO 8601 date (YYYY-MM-DD) or timestamp. Date-only values are extended to 23:59:59.999Z so the whole day is included. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |
| `sortBy` | query | string | Sort field. When omitted the list is ordered by orderDate descending. One of: `orderNumber`, `orderDate`, `totalAmount`, `createdAt`, `updatedAt`. |
| `sortDir` | query | string | Sort direction (case-insensitive). Defaults to desc. One of: `asc`, `desc`. Default: "desc". |

**Responses:** `200` — Paginated purchase order list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### POST /purchase-orders — Create a purchase order

Operation ID: `createPurchaseOrder`

Creates a DRAFT purchase order. orderNumber is server-generated from the PO sequence; totals are computed server-side from the lines with the same financial math as the web app (lineTotal 4dp, header subtotal/taxAmount/totalAmount 2dp), so API-created orders match UI-created ones to the cent. Line quantity/unitPrice are BASE units and taxRate is a fraction 0–1; a line `id` is accepted by the shared schema but ignored (create always makes new lines). The supplier must belong to your organisation and be active, every line product must be active, and warehouseId/shipToLocationId (which must belong to the chosen supplier) are validated — failures return 422 invalid_reference. Status always starts at DRAFT: progress it via POST {id}/submit → {id}/approve before goods can be received. Requires write scope and the purchasing.create permission. Consumes a sequence number, so the Idempotency-Key header is supported: retrying with the same key and body replays the stored 201 (Idempotency-Replayed: true) instead of creating a second order.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | header | string | Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h. Max length 200. |

**Request body** — `application/json` (required). Purchase order to create. quantity/unitPrice in BASE units; taxRate a fraction 0–1.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `supplierId` | string (cuid) | yes | – |
| `expectedDate` | string (date-time) \| null | no | – |
| `notes` | string \| null | no | Max length 2000. |
| `warehouseId` | string (cuid) \| null | no | – |
| `shipToLocationId` | string (cuid) \| null | no | – |
| `lines` | array of object | yes | Min 1 item(s). Max 100 items. |
| `lines[].id` | string (cuid) | no | – |
| `lines[].productId` | string (cuid) | yes | – |
| `lines[].quantity` | number | yes | Max 999999. Must be > 0. |
| `lines[].unitPrice` | number | yes | Min 0. Max 99999999. |
| `lines[].taxRate` | number | no | Default: 0. Min 0. Max 1. |
| `lines[].purchaseUomFactor` | number \| null | no | Max 1000000. Must be > 0. |
| `lines[].purchaseUomAbbrev` | string \| null | no | Max length 20. |

**Responses:** `201` — Purchase order created (status DRAFT), returned in the detail shape. · Errors: `400`, `401`, `403`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /purchase-orders/{id} — Get a purchase order

Operation ID: `getPurchaseOrder`

Fetches one purchase order in the detail shape: summary fields plus lines (ordered by sortOrder) and a goods-receipts summary (ordered by receivedAt descending).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Purchase order id (cuid). |

**Responses:** `200` — The purchase order. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### POST /purchase-orders/{id}/submit — Submit a purchase order

Operation ID: `submitPurchaseOrder`

Submits a DRAFT purchase order for approval (DRAFT → SUBMITTED). No request body. Returns 422 unprocessable when the order is not DRAFT or has no lines. Requires write scope and the purchasing.create permission (submit is gated on create, matching the web app).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Purchase order id (cuid). |

**Responses:** `200` — Purchase order submitted, returned in the detail shape with status SUBMITTED. · Errors: `401`, `403`, `404`, `422`, `429`, `503` — see [Error responses](#error-responses).

### POST /purchase-orders/{id}/approve — Approve a purchase order

Operation ID: `approvePurchaseOrder`

Approves a SUBMITTED purchase order (SUBMITTED → APPROVED), making it receivable. No request body. Returns 422 unprocessable when the order is not SUBMITTED or its supplier has been deactivated. Requires write scope and the purchasing.approve permission. Note: API keys are organisation-level credentials, so the same key may both submit and approve.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Purchase order id (cuid). |

**Responses:** `200` — Purchase order approved, returned in the detail shape with status APPROVED. · Errors: `401`, `403`, `404`, `422`, `429`, `503` — see [Error responses](#error-responses).

### POST /purchase-orders/{id}/cancel — Cancel a purchase order

Operation ID: `cancelPurchaseOrder`

Cancels a DRAFT, SUBMITTED or APPROVED purchase order (→ CANCELLED). The body is optional: { reason } (≤500 characters) is stored in the audit log only — there is no cancelReason column on the order. Returns 422 unprocessable when the order has already been (partially) received, completed or cancelled, or when active supplier returns reference it. Requires write scope and the purchasing.edit permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Purchase order id (cuid). |

**Request body** — `application/json` (optional). Optional cancellation reason, recorded in the audit log.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | no | Max length 500. |

**Responses:** `200` — Purchase order cancelled, returned in the detail shape with status CANCELLED. · Errors: `400`, `401`, `403`, `404`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### POST /purchase-orders/{id}/receipts — Record a goods receipt

Operation ID: `createGoodsReceipt`

Records a goods receipt against an APPROVED or PARTIALLY_RECEIVED purchase order (422 unprocessable otherwise, including when the supplier has been deactivated), moving stock into the given warehouse and updating weighted-average cost prices. Quantities are BASE units. receivedAt defaults to now and cannot predate the order's orderDate (422). Line rules: each line's poLineId must belong to this order and its productId must match that line (422 invalid_reference); bins must belong to the receiving warehouse and batches to your organisation. Batch-tracked lines (isBatchTracked) MUST carry a batchNumber — an existing batch with that number is reused (409 conflict if creation races; EXPIRED/RECALLED batches are rejected with 422), otherwise a new batch is created with the optional manufactureDate/expiryDate; batchId and batchNumber are mutually exclusive. Serial-tracked lines (requiresSerialTracking) need a whole-number quantity and either autoGenerateSerials: true or a serialNumbers array whose length equals quantity. Over-receipt (receiving more than ordered) is ALLOWED — supplier bonuses/rounding — and is recorded in the audit log with an OVER_RECEIPT warning. Landed costs are NOT accepted via the API (web-only in v1; add them to the receipt in the app afterwards). Status rollup: all lines fully received → RECEIVED (receivedDate stamped); some received → PARTIALLY_RECEIVED. Returns 201 with the receipt and an embedded purchaseOrder summary carrying the post-receipt status. Requires write scope and the purchasing.receive permission. Receipts move stock and consume a sequence number, so the Idempotency-Key header is supported: retrying with the same key and body replays the stored 201 (Idempotency-Replayed: true) instead of double-receiving. The key is scoped to this order's endpoint — replaying it against a different order is rejected as key reuse (422).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Purchase order id (cuid). |
| `Idempotency-Key` | header | string | Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h. Max length 200. |

**Request body** — `application/json` (required). Goods receipt to record. Quantities in BASE units. No landedCosts field — landed costs are web-only in v1.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `warehouseId` | string (cuid) | yes | – |
| `receivedAt` | string (date-time) \| null | no | – |
| `notes` | string \| null | no | Max length 2000. |
| `lines` | array of object | yes | Min 1 item(s). Max 100 items. |
| `lines[].poLineId` | string (cuid) | yes | – |
| `lines[].productId` | string (cuid) | yes | – |
| `lines[].quantity` | number | yes | Max 999999. Must be > 0. |
| `lines[].batchId` | string (cuid) \| null | no | – |
| `lines[].binId` | string (cuid) \| null | no | – |
| `lines[].batchNumber` | string \| null | no | Max length 100. |
| `lines[].manufactureDate` | string (date-time) \| null | no | – |
| `lines[].expiryDate` | string (date-time) \| null | no | – |
| `lines[].isBatchTracked` | boolean | no | Default: false. |
| `lines[].requiresSerialTracking` | boolean | no | Default: false. |
| `lines[].serialNumbers` | array of string | no | Max 1000 items. |
| `lines[].autoGenerateSerials` | boolean | no | – |

**Responses:** `201` — Goods receipt recorded; stock adjusted and cost prices updated. · Errors: `400`, `401`, `403`, `404`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

## Invoices

Invoices (read) and payment recording.

### POST /sales-orders/{id}/invoice — Create an invoice from a sales order

Operation ID: `createInvoiceFromSalesOrder`

Creates an invoice from a CONFIRMED-or-later sales order (a DRAFT order is a 422). With no body each line is billed for its not-yet-invoiced balance — what has SHIPPED (dispatchedQty) for a PARTIALLY_DISPATCHED order so the backorder isn't billed, otherwise the ordered quantity — so calling it again bills only the outstanding amount; an order whose every line is already fully invoiced is a 422. Pass `lines` (per-line { soLineId, quantity }) to invoice a partial subset; a soLineId from another order is a 422 invalid_reference and a quantity that, combined with what's already invoiced on non-VOID invoices, exceeds the ordered quantity is a 422 unprocessable. Over-invoicing is capped authoritatively under a row lock on the sales order, so concurrent creates can't both slip through. Line discounts, the order-level discount, the customer's tax-exempt status, and the sale-UOM snapshot are carried over from the sales order; invoiceNumber comes from the org's INV sequence and the due date from the customer/org payment terms. By default, when the sales order carries a Foodstuffs PO reference and the integration's auto-send is enabled, the new invoice is auto-sent over Foodstuffs EDI — set `suppressEdi: true` to skip that. Because creating consumes a sequence number (and may fire EDI), send an Idempotency-Key: a timed-out retry with the same key and body replays the stored 201 (Idempotency-Replayed: true) instead of creating — and re-sending — a second invoice. Requires the invoices.create permission and a write-scope key.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Sales order id. |
| `Idempotency-Key` | header | string | Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h. Max length 200. |

**Request body** — `application/json` (optional). Optional. Omit entirely to invoice the full outstanding balance with EDI auto-send; supply `suppressEdi` and/or `lines` to override.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `suppressEdi` | boolean | no | Default: false. |
| `lines` | array of object | no | – |
| `lines[].soLineId` | string | yes | Min length 1. |
| `lines[].quantity` | number | yes | Max 99999999. Must be > 0. |

**Responses:** `201` — The created invoice (full detail shape — same as GET /invoices/{id}). · Errors: `400`, `401`, `403`, `404`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /invoices — List invoices

Operation ID: `listInvoices`

Lists invoices, newest first by default (createdAt desc). Invoices are created from sales orders in the web app — the API surface is read-plus-payments.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on invoiceNumber or customer name. |
| `status` | query | string | Comma-separated status filter. Allowed values: DRAFT, SENT, PARTIALLY_PAID, PAID, OVERDUE, VOID — any other value is a 400 (invalid_parameter). |
| `customerId` | query | string | Only invoices for this customer id. |
| `dateFrom` | query | string | Only invoices with invoiceDate at or after this ISO 8601 date or timestamp (a date-only value means midnight UTC). 400 on an unparseable value. |
| `dateTo` | query | string | Only invoices with invoiceDate at or before this ISO 8601 date or timestamp. A date-only value (YYYY-MM-DD) is extended to end-of-day UTC (23:59:59.999Z), matching the web list. 400 on an unparseable value. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |
| `sortBy` | query | string | Sort field. One of: `invoiceNumber`, `createdAt`, `updatedAt`. Default: "createdAt". |
| `sortDir` | query | string | Sort direction (case-insensitive). One of: `asc`, `desc`. Default: "desc". |

**Responses:** `200` — Paginated invoice list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### GET /invoices/{id} — Get an invoice

Operation ID: `getInvoice`

Fetches one invoice with its lines, charges, and payments (payments most recent first, capped at 500).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Invoice id. |

**Responses:** `200` — The invoice. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### POST /invoices/{id}/payments — Record a payment

Operation ID: `recordInvoicePayment`

Records a payment against an invoice (write scope). Payments are accepted on SENT, PARTIALLY_PAID, and OVERDUE invoices; a VOID, PAID, or DRAFT invoice is a 422 (unprocessable), as is a payment exceeding the remaining balance or a currency that differs from the invoice's stored currency. paidAt, when supplied, must lie between the invoice date and now (+5 minutes clock skew). The balance and status checks run under a row lock, so concurrent payments cannot overshoot the total. Status rolls up automatically: PAID when fully settled (within half-a-cent rounding tolerance); a partial payment on an OVERDUE invoice stays OVERDUE; otherwise PARTIALLY_PAID. This is a money-moving write — send an Idempotency-Key so a timed-out retry with the same key and body replays the original 201 (Idempotency-Replayed: true) instead of recording a second payment.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Invoice id. |
| `Idempotency-Key` | header | string | Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h. Max length 200. |

**Request body** — `application/json` (required). Payment to record.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `amount` | number | yes | Payment amount in the invoice currency. Must be greater than zero and must not exceed the invoice's remaining balance (422). JSON null, booleans, arrays, and objects are rejected. Must be > 0. |
| `currency` | string | no | Optional guard: when supplied it must equal the invoice's stored currency (422 on mismatch). Payments are recorded in the invoice currency — no conversion. |
| `method` | string | no | Defaults to BANK_TRANSFER. One of: `BANK_TRANSFER`, `CREDIT_CARD`, `CASH`, `CHEQUE`, `OTHER`. Default: "BANK_TRANSFER". |
| `reference` | string \| null | no | Max length 255. |
| `notes` | string \| null | no | Max length 2000. |
| `paidAt` | string (date-time) | no | When the payment was made (ISO 8601). Must lie between the invoice date and now (+5 minutes clock-skew allowance) — 422 outside that window. Omit the field (do not send null) to record "now". |

**Responses:** `201` — Payment recorded — the created payment with an embedded invoice summary reflecting the new paidAmount, status, and balanceDue. · Errors: `400`, `401`, `403`, `404`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

## Inventory

Stock on hand and stock adjustments.

### GET /stock-on-hand — List stock on hand

Operation ID: `listStockOnHand`

Current stock per product/warehouse/bin location, with a per-location batch breakdown for batch-tracked products. Quantities are in BASE units. Rows with zero quantity are excluded unless includeZero=true; archived products' residual stock is hidden unless includeInactiveProducts=true. Default order is productName ascending (then warehouse).

Incremental sync: filter with modifiedSince and page with sortBy=updatedAt&sortDir=asc. The checkpoint is the stock-level row's updatedAt, which is bumped by every quantity/reserved change — but NOT by batch-only changes (batch merges/splits or batch status flips such as quarantine mutate the batches[] breakdown without touching the row), so those do not surface under modifiedSince.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on product SKU or name. |
| `warehouseId` | query | string | Filter to one warehouse. The id is validated against your organisation first — an unknown or foreign id returns 404 (not an empty list). |
| `productId` | query | string | Filter to one product. The id is validated against your organisation first — an unknown or foreign id returns 404 (not an empty list). |
| `includeZero` | query | string | Pass true (or 1) to include locations whose quantity is zero. One of: `true`, `1`. |
| `includeInactiveProducts` | query | string | Pass true (or 1) to include residual stock on archived (inactive) products — useful when reconciling totals. One of: `true`, `1`. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |
| `sortBy` | query | string | Sort field. updatedAt (with sortDir=asc) is what makes modifiedSince-based incremental paging stable. One of: `productName`, `updatedAt`. Default: "productName". |
| `sortDir` | query | string | Sort direction (case-insensitive). One of: `asc`, `desc`. |

**Responses:** `200` — Paginated stock-on-hand rows. · Errors: `400`, `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### GET /stock-adjustments — List stock adjustments

Operation ID: `listStockAdjustments`

Lists stock adjustments newest-first (createdAt descending), each with its lines — the same row shape the POST returns. The reason filter accepts STOCK_REQUISITION (requisition-issued adjustments move real stock, so hiding them would corrupt reconciliation) even though the create endpoint rejects that value.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `status` | query | string | Comma-separated status filter; each value is validated (400 on any bad value). |
| `reason` | query | string | Comma-separated reason filter; each value is validated (400 on any bad value). Allowed: DAMAGED, EXPIRED, LOST, FOUND, TRANSFER, PRODUCTION, CORRECTION, RETURN, STOCK_REQUISITION, OTHER. |
| `warehouseId` | query | string | Adjustments with at least one line in this warehouse. Must be a well-formed id (cuid — 400 otherwise); unlike /stock-on-hand, existence is not checked, so an unknown id simply yields an empty page. |
| `search` | query | string | Case-insensitive contains match on the adjustment number (reference). |
| `dateFrom` | query | string | Adjustments created at or after this ISO 8601 date or timestamp. |
| `dateTo` | query | string | Adjustments created at or before this ISO 8601 date or timestamp. A date-only value (YYYY-MM-DD) is extended to end-of-day UTC, so dateTo=2026-06-10 includes the 10th. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |

**Responses:** `200` — Paginated stock adjustments. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### POST /stock-adjustments — Create a stock adjustment

Operation ID: `createStockAdjustment`

Creates a manual stock adjustment (header + lines). With status COMPLETED — the default — the stock movement is applied atomically in the same transaction (insufficient stock, the reserved-quantity invariant, or bin capacity fail the whole request with 422 unprocessable). With status DRAFT only the header and lines are recorded: no stock moves and completedAt stays null.

Rules enforced by the endpoint: at most 50 lines per request; quantityChange is signed (+ stock in / − stock out) in BASE units and non-zero; decimal quantities are rejected (400) for non-divisible UNIT-of-measure products; reason cannot be STOCK_REQUISITION (400 — reserved for the internal requisitions workflow). Every body-referenced id must belong to your organisation or the request fails 422 invalid_reference — warehouses must also be active, and each bin must belong to its own line's warehouse.

This POST consumes a sequence number and (when COMPLETED) moves stock, so send an Idempotency-Key: a timed-out client can retry with the same key and body and get the stored 201 replayed (Idempotency-Replayed: true) instead of double-adjusting; a concurrent in-flight twin gets 409 conflict_in_progress, and reusing a key with a different body gets 422 idempotency_key_reuse.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | header | string | Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h. Max length 200. |

**Request body** — `application/json` (required).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | yes | One of: `DAMAGED`, `EXPIRED`, `LOST`, `FOUND`, `TRANSFER`, `PRODUCTION`, `CORRECTION`, `RETURN`, `OTHER`. |
| `notes` | string \| null | no | Max length 2000. |
| `lines` | array of object | yes | Min 1 item(s). Max 50 items. |
| `lines[].productId` | string (cuid) | yes | – |
| `lines[].warehouseId` | string (cuid) | yes | – |
| `lines[].binId` | string (cuid) \| null | no | – |
| `lines[].batchId` | string (cuid) \| null | no | – |
| `lines[].quantityChange` | number | yes | – |
| `lines[].costPerUnit` | number | no | Min 0. Max 99999999. |
| `status` | string | no | One of: `DRAFT`, `COMPLETED`. Default: "COMPLETED". |

**Responses:** `201` — Stock adjustment created. Same shape as the list/detail rows. Replays of a previous request with the same Idempotency-Key return this stored response with Idempotency-Replayed: true. · Errors: `400`, `401`, `403`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /stock-adjustments/{id} — Get a stock adjustment

Operation ID: `getStockAdjustment`

Stock adjustment detail including lines — the same shape the POST 201 returns. 404 when the id does not exist or belongs to another organisation.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Stock adjustment id. |

**Responses:** `200` — The stock adjustment. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

## Warehouses

Warehouses with zones and bins.

### GET /warehouses — List warehouses

Operation ID: `listWarehouses`

Lists the organisation's warehouses with zones and bins. Active warehouses only by default.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on code or name. |
| `includeInactive` | query | string | Pass true (or 1) to include inactive warehouses. One of: `true`, `1`. |

**Responses:** `200` — Paginated warehouse list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

## Shipments

Shipments through pick/pack/dispatch with carrier tracking.

### GET /shipments — List shipments

Operation ID: `listShipments`

Lists shipments with their sales-order summary and line count. Default sort is createdAt descending (id tiebreaker keeps pagination stable). Requires the shipments.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `status` | query | string | Comma-separated list of statuses to include (e.g. status=PACKED,DISPATCHED). Any value outside the enum is a 400 invalid_parameter. |
| `salesOrderId` | query | string | Exact match on the sales-order id (must be a valid cuid; an unknown or foreign id simply yields an empty page). |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |

**Responses:** `200` — Paginated shipment list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### GET /shipments/{id} — Get a shipment

Operation ID: `getShipment`

Fetches one shipment with its lines (sorted by sortOrder), each carrying the shipped quantity and the unitPrice/taxRate snapshot. Requires the shipments.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Shipment id. |

**Responses:** `200` — The shipment. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### POST /shipments/{id}/pack — Pack a shipment

Operation ID: `packShipment`

PICKING | PICKED → PACKED. Takes no request body (any body sent is ignored). Calling it on a shipment in any other status is a 422 unprocessable naming the current status, so plain retries are safe. Requires the shipments.create permission and a write-scope key.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Shipment id. |

**Responses:** `200` — The packed shipment. · Errors: `401`, `403`, `404`, `422`, `429`, `503` — see [Error responses](#error-responses).

### POST /shipments/{id}/dispatch — Dispatch a shipment (with carrier tracking writeback)

Operation ID: `dispatchShipment`

PACKED → DISPATCHED, stamping shipDate. The body is OPTIONAL: { carrier?, trackingNumber? } writes the 3PL carrier and tracking number onto the shipment atomically with the status flip (omit a field to keep whatever was set when the shipment was created). carrier and trackingNumber must be non-empty after trimming and ≤100 characters; trackingNumber additionally accepts only alphanumerics, hyphens, underscores, dots, and spaces — violations are a 400 validation_error. Once every sales-order line is fully shipped across all dispatched shipments, the parent sales order rolls up to DISPATCHED (from CONFIRMED/PICKING/PACKED only) and e-commerce fulfilment pushes (Shopify/WooCommerce/BigCommerce) plus the Foodstuffs ASN auto-send fire. Calling it on a non-PACKED shipment is a 422 naming the current status. Because dispatch rolls the order up and fires downstream pushes, send an Idempotency-Key: a timed-out retry with the same key and body replays the stored response (Idempotency-Replayed: true) instead of re-executing. Requires the shipments.dispatch permission and a write-scope key.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Shipment id. |
| `Idempotency-Key` | header | string | Optional client-generated key (≤200 chars) making this side-effecting POST safely retryable: a retry with the same key and body replays the original response (Idempotency-Replayed: true) instead of re-executing. Keyspace is per API key; replay window 24h. Max length 200. |

**Request body** — `application/json` (optional). Optional 3PL tracking writeback. Omit entirely to dispatch without touching carrier/tracking.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `carrier` | string | no | Min length 1. Max length 100. |
| `trackingNumber` | string | no | Min length 1. Max length 100. Pattern: `^[a-zA-Z0-9\-_.\s]+$`. |

**Responses:** `200` — The dispatched shipment (status DISPATCHED). · Errors: `400`, `401`, `403`, `404`, `409`, `413`, `422`, `429`, `503` — see [Error responses](#error-responses).

## Traceability

Batch (lot/expiry) and serial-number records and history.

### GET /batches — List batches

Operation ID: `listBatches`

Lists lot/expiry batches, ordered by expiry date ascending (FEFO order; batches without an expiry sort last). Each row carries its product summary and unit cost; the per-location quantity breakdown is on the detail endpoint.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `productId` | query | string | Batches that contain this product. The id is validated against your organisation first — an unknown or foreign id returns 404 (not an empty list). |
| `warehouseId` | query | string | Batches with at least one location in this warehouse (a batch can span warehouses). The id is validated against your organisation first — an unknown or foreign id returns 404. |
| `status` | query | string | Filter to one batch status (400 on a bad value). One of: `ACTIVE`, `QUARANTINED`, `EXPIRED`, `RECALLED`, `CONSUMED`. |
| `expiryAfter` | query | string | Batches whose expiryDate is at or after this ISO 8601 date or timestamp. |
| `expiryBefore` | query | string | Batches whose expiryDate is at or before this ISO 8601 date or timestamp. A date-only value (YYYY-MM-DD) is extended to end-of-day UTC, so expiryBefore=2026-06-10 includes the 10th. |
| `search` | query | string | Case-insensitive contains match on the batch number. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |

**Responses:** `200` — Paginated batch list. · Errors: `400`, `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### GET /batches/{id} — Get a batch

Operation ID: `getBatch`

Batch detail including the per-warehouse/bin quantity breakdown (BatchItem) and a rolled-up totalQuantity. 404 when the id does not exist or belongs to another organisation.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Batch id. |

**Responses:** `200` — The batch. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### GET /serial-numbers — List serial numbers

Operation ID: `listSerialNumbers`

Lists individual serial numbers, newest first (createdAt descending). The full status-transition history is on the detail endpoint. The `serial` filter is an exact (case-insensitive) match, not a substring search.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `productId` | query | string | Filter to one product. The id is validated against your organisation first — an unknown or foreign id returns 404 (not an empty list). |
| `status` | query | string | Filter to one serial-number status (400 on a bad value). One of: `AVAILABLE`, `RESERVED`, `ALLOCATED`, `SOLD`, `RETURNED`, `DAMAGED`, `SCRAPPED`, `IN_PRODUCTION`. |
| `serial` | query | string | Case-insensitive EXACT match on the serial number. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |

**Responses:** `200` — Paginated serial-number list. · Errors: `400`, `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### GET /serial-numbers/{id} — Get a serial number

Operation ID: `getSerialNumber`

Serial-number detail including current batch/location placement and the full status-transition history (oldest-first, capped at 500). 404 when the id does not exist or belongs to another organisation.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Serial-number id. |

**Responses:** `200` — The serial number. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

## Pricing

Price tiers and effective-price resolution.

### GET /price-tiers — List price tiers

Operation ID: `listPriceTiers`

Lists the organisation's price tiers, ordered by tierIndex. Active tiers only by default.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `modifiedSince` | query | string (date-time) | Return only rows with updatedAt at or after this ISO 8601 timestamp. On endpoints that support sortBy=updatedAt, combine with sortDir=asc for stable incremental sync. |
| `search` | query | string | Case-insensitive contains match on tier name. |
| `includeInactive` | query | string | Pass true to include inactive tiers. One of: `true`. |

**Responses:** `200` — Paginated price-tier list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### GET /price-tiers/{id} — Get a price tier

Operation ID: `getPriceTier`

Returns a price tier with its per-product tier prices and the quantity breaks of those products.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Price tier id. |

**Responses:** `200` — Price-tier detail. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### GET /products/{id}/prices — Resolve a product's effective price

Operation ID: `getProductPrices`

Resolves the effective per-base-unit price for a product, optionally for a given customer and quantity, replicating the sales-order pricing chain (default → tier → quantity break). A customerId, if supplied, must exist in your organisation (404 otherwise).

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Product id. |
| `customerId` | query | string | Resolve the price for this customer (applies their price tier and surfaces their standing discount). Must be a valid id (cuid) belonging to your organisation. |
| `quantity` | query | number | Order quantity in BASE units; selects the applicable quantity break. Positive number, default 1. Default: 1. Must be > 0. |

**Responses:** `200` — The resolved effective price. · Errors: `400`, `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

## Reference

Reference data: categories, units of measure, customer groups, sales reps, supplier locations.

### GET /categories — List product categories

Operation ID: `listCategories`

Lists the organisation's product categories so a categoryId can be resolved for product create/update. Requires the products.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on name. |

**Responses:** `200` — Paginated category list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### GET /units-of-measure — List units of measure

Operation ID: `listUnitsOfMeasure`

Lists the organisation's units of measure so a unitOfMeasureId / purchaseUomId / saleUomId can be resolved for product create/update. Requires the products.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on name or abbreviation. |

**Responses:** `200` — Paginated unit-of-measure list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### GET /customer-groups — List customer groups

Operation ID: `listCustomerGroups`

Lists the organisation's customer groups so a customerGroupId can be resolved for customer create/update. Active groups only unless includeInactive is set. Requires the sales.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on name. |
| `includeInactive` | query | string | Pass true (or 1) to include inactive rows. One of: `true`, `1`. |

**Responses:** `200` — Paginated customer-group list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### GET /sales-reps — List sales reps

Operation ID: `listSalesReps`

Lists the organisation's active members so the user id to send as a sales order's assignedToId or a customer's salesRepId can be resolved. Returns only id (the User id), name, email and role. Requires the sales.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on the member's name or email. |

**Responses:** `200` — Paginated sales-rep list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### GET /suppliers/{id}/locations — List a supplier's locations

Operation ID: `listSupplierLocations`

Lists one supplier's locations (the rows embedded in the supplier detail response) as a paginated list, so a shipToLocationId can be resolved for purchase-order create. Ordered default billing first, then default shipping, then oldest first. 404 if the supplier is not in your organisation. Requires the purchasing.view permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Supplier id (cuid). |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `search` | query | string | Case-insensitive contains match on location name or city. |

**Responses:** `200` — Paginated supplier-location list. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

## Reports

Read-only BI report endpoints.

### GET /reports/stock-movement — Stock movement ledger

Operation ID: `reportStockMovement`

Combined inventory ledger over a date range: stock adjustments, goods receipts, sales dispatches, and stock transfers (each transfer emits an OUT row at its source and an IN row at its destination). Snapshot, not paginated: the combined, date-descending result is capped at 1000 rows and silently truncated beyond that — narrow with the date window and the product/warehouse filters. Requires reports.view.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `dateFrom` | query | string (date-time) | **Required.** Start of the window (inclusive), ISO 8601 date or timestamp. |
| `dateTo` | query | string (date-time) | **Required.** End of the window (inclusive), ISO 8601. A date-only value is extended to end-of-day UTC. Must be after dateFrom (400 otherwise). |
| `productId` | query | string | Restrict to one product (cuid). 422 if it isn't in your organisation. |
| `warehouseId` | query | string | Restrict to one warehouse (cuid). 422 if it isn't in your organisation. |

**Responses:** `200` — Inventory-ledger movements (capped at 1000). · Errors: `400`, `401`, `403`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /reports/stock-on-hand-at-date — Stock on hand at a historical date

Operation ID: `reportStockOnHandAtDate`

Per-location stock as it stood at asAtDate (Unleashed-style asAtDate), reconstructed by replaying movements after that date back off current levels. Snapshot, not paginated: up to 5000 current stock rows are read (and up to 50,000 rows per movement source); rows netting to ≤0 at the date are dropped, and reserved is always 0 (historically unknowable). Requires reports.view.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `asAtDate` | query | string (date-time) | **Required.** The historical date to value stock at, ISO 8601 date or timestamp. |
| `warehouseId` | query | string | Restrict to one warehouse (cuid). 422 if it isn't in your organisation. |
| `categoryId` | query | string | Restrict to one product category (cuid). 422 if it isn't in your organisation. |

**Responses:** `200` — Per-location stock at the requested date. · Errors: `400`, `401`, `403`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /reports/valuation — Stock valuation

Operation ID: `reportValuation`

Current on-hand stock value per product, value-descending. Batch-tracked products are valued FIFO from receipt/production cost; others at the product's average cost price. Snapshot, not paginated: capped at 1000 products. Requires reports.view.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `warehouseId` | query | string | Restrict to one warehouse (cuid). 422 if it isn't in your organisation. |
| `categoryId` | query | string | Restrict to one product category (cuid). 422 if it isn't in your organisation. |

**Responses:** `200` — Per-product valuation plus the total. · Errors: `400`, `401`, `403`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /reports/receivable-aging — Accounts-receivable aging

Operation ID: `reportReceivableAging`

Outstanding (non-draft, non-void) invoices aged into buckets by days past due, due-date ascending. Amounts are in each invoice's own currency — the report MIXES currencies, so group by currency before summing. Snapshot, not paginated: capped at 1000 invoices. Requires reports.view.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `customerId` | query | string | Restrict to one customer (cuid). 422 if it isn't in your organisation. |

**Responses:** `200` — Aged outstanding invoices. · Errors: `400`, `401`, `403`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /reports/expiry — Batch expiry

Operation ID: `reportExpiry`

ACTIVE batches with an expiry date inside [today − daysBack, today + daysAhead], expiry ascending, each with its on-hand item locations (locations carry only quantity>0 items, so a batch can return an empty locations array). Snapshot, not paginated: capped at 1000 batches. Requires reports.view.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `daysAhead` | query | integer | Days into the future to include (non-negative integer). Default 90. Default: 90. Min 0. |
| `daysBack` | query | integer | Days into the past to include recently-expired batches (non-negative integer). Default 30. Default: 30. Min 0. |
| `warehouseId` | query | string | Restrict to one warehouse (cuid). 422 if it isn't in your organisation. |

**Responses:** `200` — Batches in the expiry window with their locations. · Errors: `400`, `401`, `403`, `422`, `429`, `503` — see [Error responses](#error-responses).

### GET /reports/low-stock — Low stock

Operation ID: `reportLowStock`

Active products whose available stock (on hand − reserved) has fallen to or below their minStockLevel, with a suggested reorder quantity (to maxStockLevel when set, otherwise to minStockLevel) and its estimated cost. Snapshot, not paginated: scans up to 1000 products with a minStockLevel set before applying the available≤min filter. Requires reports.view.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `warehouseId` | query | string | Restrict to one warehouse (cuid). 422 if it isn't in your organisation. |
| `categoryId` | query | string | Restrict to one product category (cuid). 422 if it isn't in your organisation. |

**Responses:** `200` — Products at or below their minimum stock level. · Errors: `400`, `401`, `403`, `422`, `429`, `503` — see [Error responses](#error-responses).

## Webhooks

Outbound webhook subscriptions and signed event delivery.

### GET /webhooks — List webhook subscriptions

Operation ID: `listWebhooks`

Lists the organisation's webhook subscriptions, newest first. Never returns the raw signing secret — only secretPrefix. Requires the webhooks.manage permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |

**Responses:** `200` — Paginated subscription list. · Errors: `400`, `401`, `403`, `429`, `503` — see [Error responses](#error-responses).

### POST /webhooks — Create a webhook subscription

Operation ID: `createWebhook`

Creates a subscription for one or more events (or "*" for all). The URL must be a public HTTPS endpoint (private/loopback hosts are rejected). A fresh HMAC-SHA256 signing secret is minted and returned in the `secret` field of THIS response ONLY — store it immediately; it is never retrievable again. Capped at 25 active subscriptions per organisation (409 when exceeded). Requires the webhooks.manage permission.

**Request body** — `application/json` (required). Subscription to create: url, events (1+), optional description.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | yes | Max length 2048. |
| `events` | array of string | yes | Min 1 item(s). Max 16 items. |
| `description` | string | no | Max length 500. |

**Responses:** `201` — Created subscription, including the show-once raw signing secret. · Errors: `400`, `401`, `403`, `409`, `413`, `429`, `503` — see [Error responses](#error-responses).

### GET /webhooks/{id} — Get a webhook subscription

Operation ID: `getWebhook`

Subscription detail (secretPrefix only; never the raw secret). 404 if the id is not in your organisation. Requires the webhooks.manage permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Webhook subscription id (cuid). |

**Responses:** `200` — Subscription detail. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### PUT /webhooks/{id} — Update a webhook subscription

Operation ID: `updateWebhook`

Merge-patch update: only keys present in the body change (omitted fields are left unchanged; explicit null on description clears it). A url change is re-validated against the same SSRF rule as create. Any successful update resets failureCount to 0 and clears disabledAt, so a repaired endpoint resumes delivery (set isActive:true to re-enable an auto-disabled subscription). Requires the webhooks.manage permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Webhook subscription id (cuid). |

**Request body** — `application/json` (required). Sparse patch — every field optional; omitted fields are left unchanged.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | no | Max length 2048. |
| `events` | array of string | no | Min 1 item(s). Max 16 items. |
| `description` | string \| null | no | Max length 500. |
| `isActive` | boolean | no | – |

**Responses:** `200` — Updated subscription. · Errors: `400`, `401`, `403`, `404`, `413`, `429`, `503` — see [Error responses](#error-responses).

### DELETE /webhooks/{id} — Delete a webhook subscription

Operation ID: `deleteWebhook`

Permanently deletes the subscription; its delivery log cascades away. 404 if the id is not in your organisation. Requires the webhooks.manage permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Webhook subscription id (cuid). |

**Responses:** `200` — Deletion confirmation. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### GET /webhooks/{id}/deliveries — List a subscription's delivery log

Operation ID: `listWebhookDeliveries`

Lists delivery attempts for the subscription, newest first. Optional status filter (PENDING|DELIVERED|DEAD). Returns delivery metadata only — no payload body, no signing material. 404 if the subscription is not in your organisation. Requires the webhooks.manage permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Webhook subscription id (cuid). |
| `page` | query | integer | Page number (1-based). Silently clamped so page × pageSize never exceeds 100,000 rows — beyond that the last reachable page repeats. Use modifiedSince windows rather than deep offsets for very large datasets. Default: 1. Min 1. |
| `pageSize` | query | integer | Rows per page (max 200; values above the cap are clamped). Default: 50. Min 1. Max 200. |
| `status` | query | string | Filter by delivery status. One of: `PENDING`, `DELIVERED`, `DEAD`. |

**Responses:** `200` — Paginated delivery log. · Errors: `400`, `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).

### POST /webhooks/{id}/ping — Send a test delivery

Operation ID: `pingWebhook`

Queues a signed test delivery (event "ping") for the subscription. The dispatcher delivers it within ~1 minute exactly like a real event, so you can confirm your endpoint and signature verification. No request body. 404 if the subscription is not in your organisation. Requires the webhooks.manage permission.

**Parameters**

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | string | **Required.** Webhook subscription id (cuid). |

**Responses:** `202` — Test delivery queued. · Errors: `401`, `403`, `404`, `429`, `503` — see [Error responses](#error-responses).
