REST API · JSON · Per-Org API Keys
A clean REST API over your Frostbyte Pro data. Sync products, customers, and suppliers, run sales and purchase orders through their full lifecycle — create, confirm, dispatch, approve, receive — generate invoices and record payments, pack and dispatch shipments, resolve prices, trace batches and serials, read live stock on hand, post stock adjustments, and pull reports — all scoped to your organisation by per-org API keys.
Base URL — all paths below are relative to it. HTTPS only.
API keys are created in Settings → API Keys by an organisation Owner or Admin. Each key belongs to exactly one organisation; everything you read or write through the API is scoped to that organisation. The full key (prefixed fbk_) is shown once at creation — store it in a secrets manager. Only a hash is kept on our side.
Pass the key on every request in the Authorization header as a bearer token (an X-API-Key header is also accepted):
curl "https://frostbytesoftware.co.nz/api/v1/products?pageSize=10&search=widget" \
-H "Authorization: Bearer fbk_your_api_key"Keys carry one or more scopes. The two global scopes — read (every GET endpoint) and write (everything, including POST and PUT) — span the whole API; a write-capable key can also read. Calling a write endpoint with a read-only key returns 403 insufficient_scope.
For least-privilege integrations you can instead issue a per-resource scope of the form <family>:read or <family>:write for any of the ten families — products, inventory, customers, suppliers, sales-orders, purchase-orders, invoices, shipments, reports, and webhooks. The family is taken from the URL's first path segment, so a key scoped to sales-orders:write can read and write sales orders but gets 403 insufficient_scope on any other family. A resource scope grants both read and write on its family when it ends in :write; combine several on one key to widen its reach without granting the global scopes. No competitor inventory API offers per-resource key scopes.
Each key may make 120 requests per minute, and 600 requests per minute aggregate across every key in your organisation — so spreading a burst over more keys raises no ceiling. Either limit surfaces as 429 rate_limit_exceeded with a Retry-After header — wait that many seconds (or until X-RateLimit-Reset) before retrying. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix epoch seconds) so your client can pace itself. For bulk syncs, page with pageSize=200 and use modifiedSince to fetch only what changed.
Repeated failed authentication from one IP address is throttled independently of these limits — fix the key rather than hammering with a bad one.
Rotate a key from Settings → API Keys to mint a fresh token while keeping the old one valid for a grace window — 24 hours by default — so you can roll the new secret out across your fleet and cut over with zero downtime. Both the new and the previous token authenticate during the window, with the same scopes; once it lapses the old token stops working and only the new one remains. Store the new secret immediately — like creation, the full rotated key is shown only once.
Keys can be revoked or given an expiry date at any time from Settings → API Keys. Revoked, expired, or unknown keys all return the same 401 invalid_api_key response.
List endpoints return { data: [...], pagination: { total, page, pageSize, totalPages } }. Single-resource endpoints return the object directly. Every error returns { error: { code, message } } with a machine-readable code and a human-readable message. Schema-validation failures (validation_error) additionally include details — an array of { path, message } covering every failing field (capped at 20), so you can surface all problems in one round trip instead of fixing them one at a time.
{
"data": [
{
"id": "cmb7x1k2p0001...",
"sku": "WIDGET-001",
"name": "Widget, Blue",
"defaultPrice": 49.95,
"costPrice": 21.5,
"taxRate": 0.15,
"imageUrl": null,
"isActive": true,
"totalStockOnHand": 240,
"updatedAt": "2026-06-01T03:21:18.000Z"
}
],
"pagination": { "total": 1, "page": 1, "pageSize": 10, "totalPages": 1 }
}{
"error": {
"code": "validation_error",
"message": "lines.0.unitPrice: Must be a number. JSON null, booleans, arrays, and objects are not accepted — omit the field to use its default.",
"details": [
{
"path": "lines.0.unitPrice",
"message": "Must be a number. JSON null, booleans, arrays, and objects are not accepted — omit the field to use its default."
}
]
}
}page (default 1) and pageSize (default 50, maximum 200 — larger values are clamped to 200).search — free-text match on the natural keys of the resource (SKU and name for products, code and name for customers and suppliers, order number for orders).modifiedSince — an ISO 8601 timestamp (e.g. 2026-06-01T00:00:00Z); returns only records updated at or after that instant. Built for incremental sync: store the time you started your last sync and pass it on the next one.0.15, not 15. Sending 15 means 1500%.null is rejected on required numeric fields — sending null (or a boolean, array, or object) for unitPrice, taxRate, amount, defaultPrice, costPrice, or costPerUnit returns 400 validation_error rather than being silently coerced to 0. Omit the field entirely to get its documented default.Don't re-pull whole resources on every poll. Combine modifiedSince with sortBy=updatedAt&sortDir=asc for a stable, ordered changes feed:
# First sync — pull everything in stable updatedAt order
GET /api/v1/products?sortBy=updatedAt&sortDir=asc&pageSize=200&page=1
# ...page through, remembering the largest updatedAt you see.
# Every poll after that — last max updatedAt minus a 1-minute overlap
GET /api/v1/products?modifiedSince=2026-06-12T02:59:00Z&sortBy=updatedAt&sortDir=asc&pageSize=200modifiedSince set to the largest updatedAt from your previous sync minus a one-minute overlap — the overlap absorbs clock skew and writes that committed while your last poll was paging. Treat re-seen records as upserts; duplicates across polls are expected and harmless.sortDir=asc explicitly — the default direction differs by resource (customers and suppliers default ascending; invoices and purchase orders default descending). Every list keeps a stable id tiebreaker, so paging order never shifts mid-sync.| Code | Meaning |
|---|---|
| 400 | Malformed request — invalid JSON, a bad query or pagination parameter (code invalid_parameter, also used for an Idempotency-Key over 200 characters), an unknown enum value, or a body that failed schema validation (code validation_error). The error message names the first offending field; validation_error responses also carry a details array listing every failing field (up to 20). |
| 401 | Missing, invalid, revoked, or expired API key. |
| 403 | The key is valid but lacks the required scope or permission (e.g. a read-only key calling a write endpoint). |
| 404 | Code not_found — the resource addressed by the URL path ({id}) does not exist in your organisation. 404 is reserved for the path resource — ids referenced inside a request body return 422 invalid_reference instead. |
| 409 | Conflict — a unique value (e.g. SKU, customer code, batch number) already exists in your organisation, or code conflict_in_progress: a request with the same Idempotency-Key is still being processed. Comes with Retry-After: 5 — retry after that delay. |
| 413 | Request body too large. JSON bodies are capped at 100 KB. |
| 422 | Well-formed but not processable. Code invalid_reference: an id referenced in the body (e.g. categoryId, a line's productId, a dispatch soLineId) does not exist in your organisation or is inactive. Code unprocessable: a business rule failed (e.g. insufficient stock, a workflow transition called in the wrong current status — the message names the actual status — credit hold, over-payment, unit-of-measure factor rules, deactivation blocked). Code idempotency_key_reuse: an Idempotency-Key was reused with a different request body or endpoint — use a unique key per logical request. |
| 429 | Rate limit exceeded — more than 120 requests in a minute on one key, or more than 600 in a minute across all of your organisation's keys (code rate_limit_exceeded). The response carries a Retry-After header (seconds); wait at least that long before retrying. |
| 503 | Code service_unavailable — a temporary problem on our side (e.g. the rate limiter's backing store is unreachable), never something wrong with your request. Comes with Retry-After: 30; retry after that delay without reducing your normal request rate. |
Write endpoints take a JSON body (max 100 KB) with Content-Type: application/json. Validation failures return 400 with the failing field named in the message; friendly duplicate checks return 409. Successful creates return 201 with the full resource; workflow transitions (confirm, dispatch, approve, cancel, pack, …) return 200 with the same detail shape as the resource's GET /{id}. The sub-resource writes — a customer's contacts and addresses — return the refreshed parent customer detail (201 on create, 200 on replace and delete), so one round-trip gives you both the updated children and the re-synced scalar mirror. Every write is recorded in your organisation's audit log, attributed to the API key that made it.
curl -X POST "https://frostbytesoftware.co.nz/api/v1/products" \
-H "Authorization: Bearer fbk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"sku": "WIDGET-001",
"name": "Widget, Blue",
"defaultPrice": 49.95,
"costPrice": 21.5,
"taxRate": 0.15
}'Five POSTs have side effects you never want twice — creating orders or adjustments, receiving goods, recording payments. All five accept an optional Idempotency-Key request header (Stripe-style): POST /sales-orders, POST /purchase-orders, POST /stock-adjustments, POST /purchase-orders/{id}/receipts, and POST /invoices/{id}/payments. Omit the header and requests behave exactly as before.
# A network timeout leaves you unsure whether the payment was recorded.
# Send an Idempotency-Key on the first attempt...
curl -X POST "https://frostbytesoftware.co.nz/api/v1/invoices/{id}/payments" \
-H "Authorization: Bearer fbk_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pay-20260612-0042" \
-d '{ "amount": 250, "reference": "BNZ batch 81" }'
# ...then retry with the SAME key and byte-identical body. If the first
# attempt actually succeeded, you get its stored 201 back — not a duplicate:
# HTTP/2 201
# Idempotency-Replayed: trueIdempotency-Replayed: true header. Successful requests are replayable for 24 hours.{id} — returns 422 idempotency_key_reuse. Retries must resend identical bytes: the comparison hashes the JSON body, so semantically-equal JSON with reordered keys counts as a different request.conflict_in_progress with Retry-After: 5.Stop polling for changes — subscribe an endpoint and we'll push events to it. The same events fire whether the change came through this API or someone edited the data in the Frostbyte Pro app, so a webhook receiver sees the complete picture.
POST /webhooks with a public HTTPS url and the events you want (one or more catalogue strings, or ["*"] for all). The 201 response carries the signing secret exactly once; afterwards only its secretPrefix is ever returned, and there is no way to re-read or rotate it — store it in your secrets manager at creation, or delete and recreate the subscription to roll it. Up to 25 active subscriptions per organisation.
curl -X POST "https://frostbytesoftware.co.nz/api/v1/webhooks" \
-H "Authorization: Bearer fbk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.yourapp.com/frostbyte",
"events": ["sales_order.created", "sales_order.dispatched"],
"description": "Sync new + dispatched orders into our OMS"
}'
# The 201 response carries the signing secret ONCE — store it now:
# { "id": "cmb...", "secret": "whsec_1a2b3c...", "secretPrefix": "whsec_1a2…",
# "url": "https://hooks.yourapp.com/frostbyte", "events": [...], ... }The complete set of emittable events. Subscribe to any of these exact strings, or to "*" (alone) for every event:
| Event | Fires when |
|---|---|
| stock.level_changed | A product's on-hand or reserved quantity changed in a warehouse (receipt, dispatch, adjustment, transfer). |
| sales_order.created | A new sales order was created (in DRAFT). |
| sales_order.confirmed | A sales order was confirmed — stock reserved and credit checked. |
| sales_order.dispatched | A sales order was dispatched (fully or partially). |
| sales_order.completed | A dispatched, fully-invoiced sales order was completed. |
| sales_order.cancelled | A sales order was cancelled and its stock reversed. |
| purchase_order.created | A new purchase order was created (in DRAFT). |
| purchase_order.approved | A purchase order was approved and is ready to receive against. |
| purchase_order.received | Goods were received against a purchase order (a receipt was recorded). |
| invoice.created | An invoice was generated from a sales order. |
| invoice.payment_recorded | A payment was recorded against an invoice. |
| product.created | A new product was added to the catalogue. |
| product.updated | An existing product's details were changed. |
| customer.created | A new customer record was created. |
| customer.updated | An existing customer's details were changed. |
Payloads are deliberately small — { id, event, occurredAt, orgId, data: { id, ... } } — carrying just enough to tell you what changed and which record. Use data.id to fetch the full resource over REST (e.g. GET /sales-orders/{id}), so you always read the current state and never act on a stale serialized copy. The event type also rides in the X-Frostbyte-Event header.
// HTTP POST to your URL. Headers:
// X-Frostbyte-Event: sales_order.created
// X-Frostbyte-Delivery-Id: cmb7x... (stable per attempt — dedupe on it)
// X-Frostbyte-Signature: t=1749693678,v1=4f3c...e1
// Body (THIN — fetch the full resource over REST using data.id):
{
"event": "sales_order.created",
"occurredAt": "2026-06-12T03:01:18.000Z",
"orgId": "cmb...",
"data": { "id": "cmb7x1k2p0001..." }
}Every delivery carries X-Frostbyte-Signature: t=<unix>,v1=<hmac>, where v1 is the hex HMAC-SHA256 of "<t>.<rawBody>" keyed by your signing secret. Recompute it over the raw request body (before any JSON parse or re-serialise — re-encoding changes the bytes) and compare in constant time. A mismatch means the request did not come from us; reject it.
import { createHmac, timingSafeEqual } from "crypto";
// rawBody MUST be the exact bytes received — verify BEFORE JSON.parse.
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
); // { t, v1 }
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`) // signed payload = "<t>.<rawBody>"
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) return false; // bad sig
// Optional: reject if Date.now()/1000 - Number(parts.t) is too large (replay).
return true;
}2xx. Verify the signature on every request and tolerate duplicates — dedupe on the stable X-Frostbyte-Delivery-Id header (the same id repeats across retries of one delivery).1m → 5m → 15m → 1h → 6h; after the last attempt the delivery is marked DEAD and stops.PUT /webhooks/{id} { "isActive": true }, which also resets the failure count to 0.GET /webhooks/{id}/deliveries is the per-subscription delivery log — each attempt's status, response code, last error, and the payload sent — so you can confirm a redelivery or chase down a miss. POST /webhooks/{id}/ping fires a signed synthetic delivery so you can validate your receiver and signature check before real events flow. Both are detailed in the Webhooks resource below.
Fifteen resources in v1. GET endpoints need the read scope; POST, PUT, and DELETE need write.
Your product catalogue: SKUs, pricing, tax, and tracking settings. The list endpoint supports free-text search, exact SKU/barcode lookup, sorting, and modifiedSince for incremental sync.
/api/v1/productsreadList products. Supports page, pageSize, search, sku, barcode, categoryId, productType, isActive / includeInactive, modifiedSince, sortBy (name | sku | createdAt | updatedAt, default name asc), sortDir.
/api/v1/products/{id}readFetch a single product by id.
/api/v1/productswriteCreate a product. sku and name are required; sku must be unique within your organisation (duplicate returns 409).
/api/v1/products/{id}writeUpdate a product. Send only the fields you want to change.
Key fields: id, sku, name, description, barcode, costPrice, defaultPrice, taxRate (fraction 0-1), imageUrl, unit of measure, category, tracking flags (batch/serial), isActive, totalStockOnHand, createdAt, updatedAt; the detail shape adds reorderQuantity, notes, purchase/sale units, variants, suppliers, and per-warehouse stock levels
sku and barcode are exact, case-insensitive matches (?sku=PUMP-1 matches “pump-1” but never “PUMP-10”) — built for connectors resolving order lines by SKU and for scan-driven flows. They AND-combine with every other filter; search stays a contains match over name, SKU, and barcode.
Customer records used on sales orders and invoices, including contact details, currency, and payment terms. Contacts and addresses are addressable as their own sub-resources — list them, and create, replace, or delete them individually.
/api/v1/customersreadList customers. Supports page, pageSize, search, code (exact match), includeInactive, modifiedSince, sortBy (name | code | createdAt | updatedAt, default name asc), sortDir.
/api/v1/customers/{id}readFetch a single customer by id.
/api/v1/customerswriteCreate a customer. Duplicate code returns 409.
/api/v1/customers/{id}writeUpdate a customer.
/api/v1/customers/{id}/contactsreadList a customer's contacts, primary first then oldest. Paginated (page, pageSize). 404 if the customer isn't in your organisation.
/api/v1/customers/{id}/contactswriteAdd a contact. Body: { name (required), role?, email?, phone?, mobile?, isPrimary? }. The first contact is always primary; passing isPrimary: true unsets the previous primary in the same transaction. Returns 201 with the refreshed customer detail (contacts + the re-synced scalar email/phone).
/api/v1/customers/{id}/contacts/{contactId}writeReplace one contact in full (same body as POST). This is a full-resource replace, not a merge — omitting an optional field clears it. isPrimary: true unsets the previous primary. The contact must belong to the customer (404). Returns 200 with the refreshed customer detail.
/api/v1/customers/{id}/contacts/{contactId}writeDelete one contact. If it was the primary, the oldest remaining contact is promoted; deleting the last contact is allowed. Returns 200 with the refreshed customer detail.
/api/v1/customers/{id}/addressesreadList a customer's addresses, default billing then default shipping then oldest. Paginated (page, pageSize). 404 if the customer isn't in your organisation.
/api/v1/customers/{id}/addresseswriteAdd an address. Body: { label?, type (SHIPPING | BILLING | BOTH, default BOTH), address?, city?, region?, suburb?, postcode?, country?, isDefaultShipping?, isDefaultBilling? }. The first address becomes default for both dimensions; an explicit isDefaultShipping / isDefaultBilling unsets the existing default for that dimension only, independently. Returns 201 with the refreshed customer detail.
/api/v1/customers/{id}/addresses/{addressId}writeReplace one address in full (same body as POST). Full-resource replace, not a merge. isDefaultShipping / isDefaultBilling: true unsets that dimension's default on the other addresses. The address must belong to the customer (404). Returns 200 with the refreshed customer detail.
/api/v1/customers/{id}/addresses/{addressId}writeDelete one address. Whichever default(s) it held are promoted to the oldest type-compatible remaining address, per dimension independently; deleting the last address is allowed. Returns 200 with the refreshed customer detail.
Key fields: id, code, name, email, phone, currency, payment terms, credit limit, isActive, createdAt, updatedAt; addresses and contacts are detail-shape fields (GET /customers/{id})
The contacts and addresses sub-resources keep a single-primary / single-default-per-dimension invariant: creating or flagging one primary contact (or one default-billing / default-shipping address) automatically clears the previous one, all in one transaction. After every write the customer's scalar email / phone / address mirror is re-synced from the current primary/defaults, so the customer header stays consistent — and the write returns the full customer detail so you see both the children and the recomputed mirror in one round-trip.
PUT on a contact or address is a full-resource REPLACE (unlike the merge-style PUT /customers/{id} header update): omit an optional field and it is cleared, not preserved. Send the whole child every time.
Deleting the last contact or address is permitted — neither the app nor the API enforces a minimum, and the scalar mirror is left intact rather than wiped when no child rows remain. These contact/address POSTs do not take an Idempotency-Key; child creation is low-risk and a duplicate just adds another row.
Supplier records used on purchase orders, including contact details and currency. The detail shape adds locations and a per-product price list with lead times.
/api/v1/suppliersreadList suppliers. Supports page, pageSize, search, code (exact match), includeInactive, modifiedSince, sortBy (name | code | createdAt | updatedAt, default name asc), sortDir.
/api/v1/suppliers/{id}readFetch a single supplier by id.
/api/v1/supplierswriteCreate a supplier. Duplicate code returns 409.
/api/v1/suppliers/{id}writeUpdate a supplier.
Key fields: id, code, name, email, phone, currency, isActive, createdAt, updatedAt; the detail shape adds locations and a priceList (per-product supplierSku, supplierPrice, leadTimeDays, minOrderQty, isPreferred)
The detail price list returns up to 500 rows (preferred rows first, then cheapest).
Sales orders through their lifecycle: create a DRAFT, confirm it (reserving stock and enforcing credit), dispatch what has been packed, complete it once invoiced, or cancel it with full stock reversal. Picking and packing happen in the Frostbyte Pro app between confirm and dispatch.
/api/v1/sales-ordersreadList sales orders. Supports page, pageSize, search, status (comma list), customerId, dateFrom / dateTo, modifiedSince, sortBy (orderDate | orderNumber | totalAmount | createdAt | updatedAt, default orderDate desc), sortDir.
/api/v1/sales-orders/{id}readFetch a single sales order including its lines.
/api/v1/sales-orderswriteCreate a DRAFT sales order with lines. The customer and every line product must belong to your organisation. Supports the Idempotency-Key header.
/api/v1/sales-orders/{id}/confirmwriteConfirm a DRAFT order (→ CONFIRMED). No request body. Reserves stock for every line in the order's warehouse and enforces credit: a customer on credit hold always blocks (422); going over the credit limit blocks when your organisation's credit enforcement mode is BLOCK. A missing warehouse or insufficient stock (the message names the product and the available vs required quantities) also returns 422.
/api/v1/sales-orders/{id}/dispatchwriteDispatch packed goods (PACKED or PARTIALLY_DISPATCHED → DISPATCHED or PARTIALLY_DISPATCHED). Body is optional: { lines: [{ soLineId, quantity }] } dispatches a partial wave; omitting the body, omitting lines, or sending an empty lines array dispatches everything packed but not yet dispatched. Per-line quantities are clamped to the packed-not-yet-dispatched balance — there is no over-dispatch error. A soLineId that doesn't belong to the order returns 422 invalid_reference. Supports the Idempotency-Key header — recommended for partial waves, where a timed-out retry would otherwise dispatch the same wave twice.
/api/v1/sales-orders/{id}/completewriteComplete a DISPATCHED order (→ COMPLETED). No request body. Every line must be fully invoiced by non-VOID invoices; an under-invoiced line returns 422 naming the product and the invoiced vs ordered quantities.
/api/v1/sales-orders/{id}/cancelwriteCancel a DRAFT, CONFIRMED, PICKING, or PACKED order (→ CANCELLED). Body is optional: { reason } (max 500 chars, recorded against the order and in the audit log). Blocked with 422 once any units have shipped (create a customer return instead), or while active invoices or customer returns exist.
/api/v1/sales-orders/{id}/invoicewriteGenerate an invoice from a CONFIRMED-or-later sales order. Body is optional: { suppressEdi? (default false), lines?: [{ soLineId, quantity }] }. With no lines each line is billed for its not-yet-invoiced balance (dispatched quantity on a PARTIALLY_DISPATCHED order), so repeat calls bill only the outstanding amount; an already fully-invoiced order returns 422, as does over-invoicing a line (the cumulative cap re-checks under a row lock). A soLineId that doesn't belong to the order returns 422 invalid_reference. Returns 201 with the created invoice in the GET /invoices/{id} detail shape. Supports the Idempotency-Key header — the key is bound to this order, so a retry replays the original 201 instead of creating (and re-sending) a second invoice.
Key fields: id, orderNumber, status, customer, orderDate, lines (product, quantity in base units, unitPrice, discountPct and taxRate as fractions 0-1, line total), subtotal, tax total, total, createdAt, updatedAt
Transitions return 200 with the same detail shape as GET /sales-orders/{id}. Calling a transition in the wrong current status returns 422 unprocessable with a message naming the actual status — which is also what a concurrent double-transition sees, since every transition runs in a single transaction holding a row lock on the order.
Stock semantics: confirm reserves stock (aggregated across bins per warehouse). Dispatch never touches stock — it was deducted at picking; dispatch advances dispatched quantities, stamps the ship date on the first dispatch only, and moves the dispatched lines' allocated serial numbers to SOLD (whole units). A full dispatch also pushes fulfilment to a linked Shopify, WooCommerce, or BigCommerce order. Cancel releases reservations (CONFIRMED) or returns exactly the picked quantity to stock and releases the unpicked balance (PICKING/PACKED), and returns allocated serials to AVAILABLE.
Sales-order amounts are always in your organisation's base currency — there is no currency field on a sales order. Invoices carry a currency label.
POST /sales-orders/{id}/invoice is the one way to create an invoice through the API (invoices are otherwise read-only). Invoice creation can auto-send the invoice over Foodstuffs EDI — it does so when the order carries a Foodstuffs PO reference and your Foodstuffs integration has auto-send enabled. Pass suppressEdi: true to create the invoice without sending (e.g. for drafting or replays). The EDI send is fire-and-forget, so the 201 returns the created invoice even if a later send fails. The invoiceDate (today) and dueDate (from your payment terms) are server-derived — there is no override.
Invoices for reporting and reconciliation, plus payment recording. Invoices themselves are generated from sales orders inside Frostbyte Pro; once issued, payments against them can be recorded through the API.
/api/v1/invoicesreadList invoices. Supports page, pageSize, search, status (comma list), customerId, dateFrom / dateTo, modifiedSince, sortBy (invoiceNumber | createdAt | updatedAt, default createdAt desc), sortDir.
/api/v1/invoices/{id}readFetch a single invoice including its lines and payments.
/api/v1/invoices/{id}/paymentswriteRecord a payment against a SENT, PARTIALLY_PAID, or OVERDUE invoice. Body: { amount (required, > 0), currency? (must match the invoice's currency; defaults to it), method? (BANK_TRANSFER | CREDIT_CARD | CASH | CHEQUE | OTHER, default BANK_TRANSFER), reference? (max 255), notes? (max 2000), paidAt? (ISO 8601 timestamp, defaults to now; must lie between the invoice date and now) }. Returns 201 with the created payment, carrying an embedded invoice summary with the updated paidAmount, status, and balanceDue.
Key fields: id, invoiceNumber, status, customer, issue date, due date, currency, lines, subtotal, tax total, total, amount paid / due, payments (id, amount, paymentDate, method, reference), createdAt, updatedAt
Invoices cannot be created or edited via the API — payment recording is the single invoice write in v1.
Payments are recorded in the invoice's stored currency; there is no conversion. A currency mismatch, a payment on a VOID, PAID, or DRAFT invoice, or an amount exceeding the remaining balance all return 422 — and the balance check re-runs under a row lock, so concurrent payments can never jointly overpay. The invoice becomes PAID at the full total (within a half-cent rounding tolerance); a partial payment on an OVERDUE invoice stays OVERDUE; otherwise PARTIALLY_PAID.
Money moves once and only once: send an Idempotency-Key header so a timed-out retry replays the original 201 instead of double-recording — see Idempotency under Conventions.
Purchase orders through their lifecycle: create a DRAFT, submit it for approval, approve it, record goods receipts against it — including batch and serial intake — or cancel it.
/api/v1/purchase-ordersreadList purchase orders. Supports page, pageSize, search, status (comma list), supplierId, dateFrom / dateTo, modifiedSince, sortBy (orderNumber | orderDate | totalAmount | createdAt | updatedAt), sortDir. Without sortBy the list is ordered orderDate desc.
/api/v1/purchase-orders/{id}readFetch a single purchase order including its lines.
/api/v1/purchase-orderswriteCreate a DRAFT purchase order with lines. The supplier and every line product must belong to your organisation. Supports the Idempotency-Key header.
/api/v1/purchase-orders/{id}/submitwriteSubmit a DRAFT order for approval (→ SUBMITTED). No request body. A purchase order with no lines returns 422.
/api/v1/purchase-orders/{id}/approvewriteApprove a SUBMITTED order (→ APPROVED). No request body. 422 if the supplier has been deactivated.
/api/v1/purchase-orders/{id}/cancelwriteCancel a DRAFT, SUBMITTED, or APPROVED order (→ CANCELLED). Body is optional: { reason } (max 500 chars, recorded in the audit log — same contract as the sales-order cancel). Blocked with 422 while active supplier returns exist; an order that has already received goods cannot be cancelled.
/api/v1/purchase-orders/{id}/receiptswriteRecord a goods receipt against an APPROVED or PARTIALLY_RECEIVED order. Body: { warehouseId (an active warehouse in your organisation), receivedAt? (defaults to now; cannot predate the order date), notes? (max 2000), lines (1-100) } — see the notes for line rules. Returns 201 with the receipt — id, reference (GR-…, server-generated), receivedAt, warehouseId, notes, lines — and the order's post-receipt status: RECEIVED once every line is fully received (stamping receivedDate), else PARTIALLY_RECEIVED.
Key fields: id, orderNumber, status, supplier, order date, expected date, currency, lines (product, quantity in base units, unit cost, taxRate as fraction 0-1), subtotal, tax total, total, createdAt, updatedAt
Transitions return 200 with the same detail shape as GET /purchase-orders/{id}; the wrong current status returns 422 unprocessable naming the actual status. API keys are organisation-level credentials, so one write key can create, submit, AND approve the same purchase order — if your organisation relies on two-person approval, keep the approve step in the app or give the approving system its own key and process.
Receipt lines: { poLineId, productId (must match the PO line's product), quantity (base units, > 0), binId? }. Batch-tracked products require batchNumber — it resolves to an existing batch or auto-creates a new one (expired or recalled batches are rejected with 422); batchId and batchNumber are mutually exclusive. Serial-tracked products need serialNumbers matching the quantity exactly, or autoGenerateSerials: true — and whole-number quantities. Up to 1000 serials per line; received serials are created as AVAILABLE and linked to the PO line.
Over-receipt (receiving more than ordered) is allowed and flagged in the audit log. Landed costs cannot be added via the API — add them to a receipt in the app; cost prices still update on receipt via the weighted-average path, so a missing exchange rate surfaces as 422. Marking a fully received order COMPLETED also remains in-app.
Receipts move stock and consume a receipt number, so the endpoint supports the Idempotency-Key header — see Idempotency under Conventions.
Changed 2026-06-12: sortBy without an explicit sortDir now sorts descending, matching /sales-orders (it previously sorted ascending). Pass sortDir=asc if you relied on the old order — see the changelog.
Current stock levels per product per warehouse — the same numbers the app shows. Quantities are in base units. Each row carries updatedAt, the checkpoint value for incremental sync.
/api/v1/stock-on-handreadList stock levels. Supports page, pageSize, search (by SKU or product name), warehouseId, productId, includeZero, includeInactiveProducts, modifiedSince, sortBy (productName | updatedAt, default productName asc), sortDir.
Key fields: product (id, sku, name), warehouse (id, code), quantity on hand, quantity reserved/allocated, quantity available, batch breakdown for batch-tracked products, updatedAt
modifiedSince covers every quantity or reservation change, but batch-only changes (a batch quarantined, merged, or split) update the batch breakdown without bumping the row's updatedAt — they will not surface under modifiedSince.
Archived products' residual stock is hidden by default; pass includeInactiveProducts=true to see it for reconciliation.
The warehouses configured for your organisation. Use warehouse ids when filtering stock on hand or posting stock adjustments.
/api/v1/warehousesreadList warehouses.
Key fields: id, code, name, isActive, zones (with their bins)
Zones and bins are capped per response: up to 50 zones per warehouse and up to 500 bins per zone.
Adjust on-hand stock up or down with an audited reason — for cycle counts, write-offs, or corrections driven by an external system — and read adjustments back for reconciliation.
/api/v1/stock-adjustmentsreadList stock adjustments, newest first. Supports page, pageSize, search (adjustment number), status and reason (comma lists), warehouseId (adjustments with any line in that warehouse), dateFrom / dateTo (createdAt), modifiedSince.
/api/v1/stock-adjustments/{id}readFetch a single stock adjustment including its lines.
/api/v1/stock-adjustmentswriteCreate a stock adjustment. The product and warehouse must belong to your organisation; the resulting movement appears in the app's stock history. Supports the Idempotency-Key header.
Key fields: id, adjustmentNumber, reason, status (DRAFT | COMPLETED | VOIDED), notes, completedAt, createdAt, updatedAt, lines (productId, warehouseId, binId, batchId, quantityChange — signed, in base units — costPerUnit); the same shape is returned by POST, the list, and the detail endpoint
Adjustments that would take batch- or serial-tracked stock negative are rejected with 422.
A single request can contain at most 50 adjustment lines.
The reason FILTER accepts STOCK_REQUISITION (requisition-issued adjustments move real stock, so hiding them would corrupt reconciliation), but POST rejects that reason — it is reserved for the in-app requisitions workflow.
Fulfilment view of your sales orders for 3PL and warehouse integrations: pick / pack / dispatch state plus carrier and tracking. Shipments are created from sales orders inside Frostbyte Pro; the API exposes the read view and the two transitions a 3PL writes back — pack and dispatch.
/api/v1/shipmentsreadList shipments, newest first (id tiebreaker). Supports page, pageSize, status (comma list, validated against the shipment statuses — bad value returns 400), salesOrderId (cuid format-checked; an unknown id just returns an empty page, never an existence oracle), modifiedSince.
/api/v1/shipments/{id}readFetch a single shipment including its lines.
/api/v1/shipments/{id}/packwriteMark a picked shipment packed (PICKING or PICKED → PACKED). No request body. Calling it in the wrong status returns 422 naming the actual status. Returns 200 with the shipment detail.
/api/v1/shipments/{id}/dispatchwriteDispatch a packed shipment (PACKED → DISPATCHED), stamping the ship date. Body is optional: { carrier?, trackingNumber? } (each non-empty, ≤ 100 chars; tracking restricted to letters, digits, spaces and - _ .) — written onto the shipment in the same transaction (the 3PL tracking writeback). Once every line of the parent sales order is fully shipped, the order rolls up to DISPATCHED and fulfilment is pushed to a linked Shopify / WooCommerce / BigCommerce order and the Foodstuffs ASN auto-sent. Supports the Idempotency-Key header so a timed-out retry replays instead of re-running the rollup and pushes. Wrong status returns 422.
Key fields: id, shipmentNumber, status, salesOrder (id, orderNumber), carrier, trackingNumber, totalWeight, shipDate, notes, lineCount, createdAt, updatedAt; the detail shape adds lines (soLineId, productId, product {sku, name}, quantity in base units, unitPrice, taxRate as a fraction 0-1)
Only pack and dispatch are exposed — creating, picking, completing picking, cancelling, and delivering a shipment stay in the app. To correct carrier / tracking after a successful dispatch, send the dispatch again only if the shipment is still PACKED; a replay under the same Idempotency-Key with a different body returns 422 idempotency_key_reuse, so use a fresh key (or set tracking at the first dispatch).
The recall and FEFO traceability moat, read-only: lot/expiry batches with their per-location quantities, and individual serial numbers with their full status-transition history. Both are scoped to your organisation and gated on inventory view.
/api/v1/batchesreadList batches in FEFO order (earliest expiry first, id tiebreaker). Supports page, pageSize, productId and warehouseId (both validated in-org → 404; warehouseId matches any batch with stock in that warehouse), status (a single batch status — bad value returns 400), expiryAfter / expiryBefore (ISO; a date-only expiryBefore covers the whole day in UTC), search (contains on batchNumber), modifiedSince.
/api/v1/batches/{id}readFetch a single batch with its per-warehouse / bin breakdown (items[]) and a rolled-up totalQuantity. The breakdown is capped at 1000 locations, and totalQuantity sums only the returned items.
/api/v1/serial-numbersreadList serial numbers, newest first (id tiebreaker). Supports page, pageSize, productId (validated in-org → 404), status (a single serial status — bad value returns 400), serial (an exact, case-insensitive match on the serial number, never a substring), modifiedSince.
/api/v1/serial-numbers/{id}readFetch a single serial number with its current batch / warehouse / bin placement and its full status-transition history (oldest first, capped at 500 entries).
Key fields: Batch: id, batchNumber, status, manufactureDate, expiryDate, product (id, sku, name — derived from the batch's items, null for an empty batch), unitCost, notes, createdAt, updatedAt; the detail adds items[] (per warehouse / bin quantity) and a rolled-up totalQuantity. SerialNumber: id, serialNumber, status, product, receivedAt, soldAt, returnedAt, notes, createdAt, updatedAt; the detail adds the current batch / warehouse / bin placement and the full transition history
A batch has no product of its own — its product is derived from its line items, so an item-less batch returns product: null. In practice every batch is created against one product.
modifiedSince on batches keys off the batch's own updatedAt, so a quantity change that touches only a batch-item row (a merge or split) without bumping the batch will not surface — the same incremental-sync caveat as Stock On Hand.
Price tiers and an effective-price resolver, so a webstore or connector can price a line exactly the way the Frostbyte Pro sales-order form does — without re-implementing the pricing engine. All three reads are gated on products view.
/api/v1/price-tiersreadList price tiers in ranking order (tierIndex asc). Supports page, pageSize, search (name), includeInactive, modifiedSince.
/api/v1/price-tiers/{id}readFetch a single price tier, including the per-product tier prices and the quantity breaks of those products (up to 1000 product prices).
/api/v1/products/{id}/pricesreadResolve the effective per-base-unit price for a product. Optional query: customerId (a customer in your organisation — an unknown id returns 404, since it changes the answer) and quantity (positive, default 1; a bad value returns 400). Returns the base price, the resolved price, and where it came from.
Key fields: PriceTier: id, tierIndex, name, isActive, productPriceCount, customerCount, createdAt, updatedAt; the detail adds productPrices (productId, sku, productName, price) and quantityBreaks (productId, minQuantity, unitPrice). Resolver result: productId, customerId, quantity, basePrice, resolvedPrice, source (default | tier | quantity_break | customer_discount), appliedTierId, appliedDiscountPct
Resolver precedence mirrors the sales-order form exactly: the product's defaultPrice, then the customer's price tier if one applies, then a quantity break if the ordered quantity reaches one. A quantity break OVERRIDES the tier — they never stack — and source reports the last step that moved the price. Prices and quantities are in base units.
The customer's standing discount is informational only: it is surfaced as appliedDiscountPct (a fraction 0-1) but never folded into resolvedPrice, because the app applies it to the line TOTAL, not the unit price. So source is never customer_discount today — the value exists for forward-compatibility — and resolvedPrice is the pre-discount unit price you'd put on the line.
Read-only lookups for the ids the write endpoints ask for — the same lists the app shows in its dropdowns. Resolve a categoryId, a unit-of-measure id, a customerGroupId, a sales-rep user id, or a supplier's shipping locations before you create or update the parent record.
/api/v1/categoriesreadList product categories (products.view), ordered by sort order then name. Supports page, pageSize, search (name).
/api/v1/units-of-measurereadList units of measure (products.view). type (WEIGHT | VOLUME | LENGTH | AREA | UNIT | TIME) drives the divisibility rule on stock adjustments. Supports page, pageSize, search (name or abbreviation).
/api/v1/customer-groupsreadList customer groups (sales.view), active only by default. Supports page, pageSize, search (name), includeInactive.
/api/v1/sales-repsreadList your active members as sales-assignment targets (sales.view) — the id to send as a sales order's assignee or a customer's sales rep. Returns only id, name, email, and role — no other user details. Supports page, pageSize, search (name or email).
/api/v1/suppliers/{id}/locationsreadList one supplier's locations (purchasing.view) — resolve a ship-to location for a purchase order without pulling the whole supplier. 404 if the supplier isn't in your organisation. Supports page, pageSize, search (location name or city).
Key fields: Category: id, name, parentId, sortOrder. UnitOfMeasure: id, name, abbreviation, type. CustomerGroup: id, name, isActive. SalesRep: id (the user id), name, email, role. SupplierLocation: id, name, type, contact and structured address fields, isDefaultShipping, isDefaultBilling
The sales-reps id is the USER id (what a sales order's assignee / a customer's sales rep points at), not the membership row id. The list returns nothing beyond id, name, email, and role.
Read-only BI snapshots — the same figures the app's reports show, gated on reports view. These are point-in-time snapshots, not paginated lists: each builder caps its result (noted per endpoint) and silently truncates beyond the cap, so narrow with the filters when a dataset is large.
/api/v1/reports/stock-movementreadInventory ledger over a date range (adjustments, receipts, dispatches, transfers; quantityChange is signed, in base units). Requires dateFrom and dateTo (ISO; a date-only dateTo covers the whole day, and dateTo must be after dateFrom); optional productId / warehouseId. Capped at 1000 rows.
/api/v1/reports/stock-on-hand-at-datereadPer-location stock as it stood at a historical date, reconstructed from current levels. Requires asAtDate (ISO); optional warehouseId / categoryId. reserved is always 0 (historically unknowable). Up to 5000 current rows.
/api/v1/reports/valuationreadCurrent on-hand value per product, plus a total. Optional warehouseId / categoryId. Capped at 1000 products. The total assumes a single (base/org) currency.
/api/v1/reports/receivable-agingreadOutstanding invoices aged into buckets (current | days1to30 | days31to60 | days61to90 | days90plus). Optional customerId. Capped at 1000 invoices. Amounts are in each invoice's own currency — this report MIXES currencies, so group by currency before summing.
/api/v1/reports/expiryreadActive batches expiring within a window, each with its on-hand locations (which may be empty). Optional daysAhead (default 90), daysBack (default 30), warehouseId. Capped at 1000 batches.
/api/v1/reports/low-stockreadActive products at or below their minimum stock level, with a suggested reorder quantity and its estimated cost. Optional warehouseId / categoryId. Scans up to 1000 products.
Key fields: Each report returns an object wrapping its row array(s) — movements, rows, items + totalValue, invoices, batches, or products — with the exact row fields listed in the OpenAPI document and the markdown reference
A filter id that isn't in your organisation (warehouseId, categoryId, productId, customerId) returns 422 invalid_reference. There are no path {id} resources here, so these endpoints never return 404.
Hitting a builder's cap gives you a partial report with no flag in the response — the snapshot is intentionally unpaginated. Narrow the date window or filters to stay under the cap.
Outbound webhooks push events to your endpoint instead of you polling for changes. Subscribe a public HTTPS URL to one or more events; when a matching change is committed — whether it came through this API or someone working in the Frostbyte Pro app — we POST a small, HMAC-signed payload to your URL, retrying with backoff until you acknowledge it. Manage subscriptions, inspect the delivery log, and fire a test ping through the endpoints below. See the Webhooks subsection under Conventions for the payload shape, the signature scheme, and delivery semantics.
/api/v1/webhooksreadList your organisation's webhook subscriptions. Supports page, pageSize. The signing secret is never included — only secretPrefix.
/api/v1/webhooks/{id}readFetch a single subscription by id (404 if it isn't in your organisation). Returns secretPrefix, never the full secret.
/api/v1/webhookswriteCreate a subscription. Body: { url (a public HTTPS endpoint — loopback, private, and link-local hosts are rejected 422/400), events (1+ of the catalogue strings, or ["*"] for all — an unknown event is rejected), description? (≤ 500 chars) }. Returns 201 with the subscription AND the full signing secret in a secret field — this is the ONLY time the secret is returned, so store it now. A 26th active subscription returns 422 (cap of 25 per organisation).
/api/v1/webhooks/{id}writeUpdate a subscription. Send only what changes: { url?, events?, description? (null clears it), isActive? }. Setting isActive: true re-enables an auto-disabled endpoint and resets its failure count to 0. The signing secret cannot be read or rotated through this endpoint — delete and recreate to roll the secret.
/api/v1/webhooks/{id}writeDelete a subscription and stop all further deliveries to it. Its delivery-log rows are removed with it. Returns 200.
/api/v1/webhooks/{id}/deliveriesreadThe delivery log for one subscription, newest first — each attempt's event, status (PENDING | DELIVERED | DEAD), attempts, responseStatus, lastError, deliveredAt, and the thin payload that was (or will be) sent. Supports page, pageSize and a status filter. Use it to debug a missed event or confirm a redelivery succeeded.
/api/v1/webhooks/{id}/pingwriteEnqueue a synthetic test delivery to the subscription's URL so you can verify your receiver, signature check, and 2xx acknowledgement before relying on real events. The ping is signed and logged exactly like a real delivery; it goes out on the next dispatch run (~1 min). Returns 202.
Key fields: id, url, events (the subscribed event strings, or ["*"] for all), description, secretPrefix (the only fragment of the signing secret ever returned — the full secret is shown once at creation), isActive, failureCount, lastSuccessAt, lastFailureAt, disabledAt, createdAt, updatedAt. The signing secret itself (secretEnc) is stored AES-encrypted and is NEVER returned after creation. Delivery rows carry: id, event, status (PENDING | DELIVERED | DEAD), attempts, responseStatus, lastError, deliveredAt, createdAt, and the thin payload
The full signing secret is returned ONLY in the 201 from POST /webhooks. Afterwards every read returns secretPrefix alone — there is no endpoint that re-reveals or rotates it. Lost the secret? Delete the subscription and create a fresh one.
Webhooks fire for changes made through this API AND for the same actions performed by a person in the Frostbyte Pro app — they reflect the data, not the channel.
Up to 25 active subscriptions per organisation. Subscribe one URL to several events (or ["*"] for all) rather than creating a subscription per event.
v1.3 rounded out the read/write surface — reference-data lookups, price tiers and an effective-price resolver, batch and serial traceability, the shipment pick / pack / dispatch view, read-only reports, and the customer-contact / address sub-resources — and v1.4 adds signed outbound webhooks, so you can stop polling for changes. A few steps remain in-app only: picking sales orders, creating and cancelling shipments, adding landed costs to a goods receipt, and marking a fully received purchase order COMPLETED. If your integration needs any of these, tell us — it directly shapes the v2 roadmap.
Additive changes ship on v1 without a version bump — new response fields, new optional query parameters, and new endpoints can appear at any time. Build your client to tolerate unknown response fields (parse what you know, ignore the rest); a JSON decoder that rejects unrecognised keys will break on routine releases.
Breaking changes get a new path version — /api/v2 would run alongside /api/v1, so existing integrations keep working while you migrate on your own schedule. Renaming or removing a field, changing a type, or tightening semantics on an existing parameter all count as breaking.
Deprecations are announced on the changelog with a sunset date before anything is switched off. Every release — additive or otherwise — is recorded there, so it is the one page to watch.
View the API changelogEverything on this page also ships as an OpenAPI 3.1 document at https://frostbytesoftware.co.nz/api/v1/openapi.json. Fetching it needs no API key, and it is generated from the same validation schemas the API enforces — the request bodies, parameter whitelists, enums, and error responses in the spec are the ones the server actually applies, not a hand-maintained copy.
# No API key needed — the spec is documentation, not data
curl https://frostbytesoftware.co.nz/api/v1/openapi.jsonImport → Link → paste the spec URL. Postman builds a collection with every endpoint, parameter, and request body — add your fbk_ key as a bearer token and you're making calls.
The spec is standard OpenAPI 3.1, so any generator that reads it — openapi-generator, orval, and friends — can produce a typed client for your stack. We don't ship an official SDK: the spec is the contract, so pick whichever generator fits your toolchain.
This whole reference as one plain-text page at /developers/api-reference.md — built for AI agents and LLMs (and anyone who prefers plain text): no JavaScript to render, trivial to drop into a context window.
/llms.txt at the site root points AI tools at the machine-readable docs — the markdown reference and the OpenAPI spec — so an agent pointed at frostbytesoftware.co.nz finds the API without scraping this page.
A conformance test walks the live route tree on every build and fails it if an endpoint exists that the document doesn't describe — the spec cannot silently drift from the API.
Join businesses across New Zealand who trust Frostbyte Pro to manage their inventory. Start your free 14-day trial today, no credit card required.