Docs / Reference
API reference
The public REST API, included in every plan.
The OrderDen public API is a REST API over everything the app does — clients, the item catalog, quotes, orders and shipments, invoices and payments, vendors, purchases, spreadsheet imports, the activity trail, dashboard data, search and settings. It's included in every plan, Free included — unmetered by volume, with a per-minute rate per key (see Plans & billing) — and every new OrderDen feature ships with API coverage.
Try it live: the interactive API explorer renders every endpoint with parameters and schemas — click Authorize, paste an API key, and run requests against your own workspace. The machine-readable OpenAPI specification behind it also works with Postman, code generators and other OpenAPI tooling.
Authentication
Create a key in Company Settings → API keys (owners/admins). The secret —
od_live_ followed by 40 hex characters — is shown once; only its hash
is stored. Send it as a bearer token on every request:
curl -s https://your-domain.example/api/v1/clients \
-H "Authorization: Bearer od_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
A key acts in one workspace and can be revoked at any time. Writes made with a key appear in the activity trail attributed to the key by name.
A key carries the permissions of whoever created it, read fresh on every
request — so taking somebody's access away takes away what their keys can do
too. A key made by somebody without See costs and margin gets responses with
every cost, margin, fee and profit field removed, not zeroed; a key without
Create and edit documents gets a 403 on the writes that need it. A key whose
creator has left the workspace falls back to read-only. Call GET /me first
from a new integration and read what it says rather than discovering a missing
field as a gap. See Roles, permissions, two-factor and
sessions.
There is no monthly request quota. The one limit is a per-minute rate per
key — 60 a minute on Free, 300 on OrderDen — which exists to catch a runaway
script rather than to be bought back. GET /billing reports what your
workspace has been calling and the rate it may call at.
Conventions
- Base path
/api/v1; JSON in and out. - Success responses wrap the payload:
{ "data": … }; list endpoints add"meta": { total, page, pageSize, pageCount }and take?page=…&pageSize=…plus per-resource filters. - Sorting: every list takes
?sort=fieldor?sort=field:asc/field:desc(e.g.sort=total:desc), restricted to a per-list set of sortable fields — an unknown field is rejected with a message naming the valid ones. Omitting the direction uses the field's natural default (text A→Z, dates and amounts newest/largest first). - CSV export: every list also takes
?format=csv, which streams the entire filtered result set (paging is ignored) as a CSV download in the current sort order.columns=(comma-separated ids) picks and orders the columns; omit it for all columns. - Errors are
{ "error": { "code", "message", "details"? } }with matching HTTP status — 400 validation, 401 bad/missing key, 402 plan limit reached (a free workspace has issued all 50 of its invoices, or its seats are full;details.limitisinvoicesorseats), 403 forbidden (the key's permissions don't cover this;details.permissionnames the flag), 404 not found, 409 conflict (the write clashes with the resource's current state — a duplicate number, an illegal status transition, a locked document), 422 unprocessable (a business rule against the data, e.g. shipping more than remains open), 429 rate limited. - Polling: every list takes
?updatedSince=<ISO timestamp>and returns rows changed at or since that instant. Inclusive — store the newestupdatedAtyou saw and send it back, and you see that one row again rather than missing one written in the same millisecond. - Retrying a write: send an
Idempotency-Keyheader (any unique string) on a POST, PUT, PATCH or DELETE. Retrying with the same key replays the first response for 24 hours and creates nothing twice; the same key with a different body is a 409, because that is a bug in the caller. - Rate-limit headers on every response:
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(unix seconds). - Money is decimal strings (
"149.50") everywhere, including figures the server works out rather than reads off a record — a report's revenue and margin, a dashboard money stat, a project rollup, a price suggestion, a stock valuation. Unit costs carry the stock ledger's four decimals instead of two ("6.1000"). Percentages, counts and quantities stay numbers: they are not amounts. Dates are ISO-8601. Partial updates usePATCH. Document numbers are server-assigned from your configured formats whennumberis omitted; quotes, orders, invoices, purchases and payments all accept one of your own on create, andPATCHrenames it (409 when taken). Derived statuses (fulfilment, payment states) can't be set directly. - Lines:
linesmay be empty on a quote, order or invoice as long as the document still comes to more than zero — freight or tax billed on its own. A document that would come to nothing with no lines is rejected 422. - Line ids on an update: every line comes back with an
id. When aPATCHsendslines, send those ids back with them: a line carrying itsidis rewritten in place, one without is written as new, and one whose id you stop sending is deleted. That is what keeps billed time and expenses attached to the invoice line they were billed on, so you can reorder and edit freely as long as the ids travel with their lines. Drop them and every line is read as new, which detaches that work and hands it back to Ready to bill. Anidthat is not a line on that document, or the same one sent twice, is rejected 422;idis ignored onPOST, so a document read back and posted as a copy needs nothing stripped. - Discounts: documents and their lines take an optional
discountType(PERCENTorAMOUNT) with adiscountValue— both or neither;discountType: nullonPATCHclears it. Line discounts come offquantity × unitPrice, the document discount offsubtotalbefore tax, and responses add the resolveddiscountAmountat both levels. - Cost basis: quote, order and invoice lines return a read-only
unitCost— the catalog item's unit cost as of when the line was written (nullwhen unknown), snapshotted on create and wheneverlinesare replaced, and carried unchanged through convert, invoice and duplicate. A line with noitemIdmay setunitCostexplicitly; on an item line it is ignored in favour of the catalog. - Gross margin: every order returns a read-only
margin— net revenue (line totals after discounts plusshippingAmount, tax excluded) against the cost basis (each line'squantity × unitCostplus every recorded shipmentcost), asmarginandmarginPercent, per line underlines, withcoveragesaying how many lines and shipments have a cost. A line or shipment with no cost is unknown, never zero: it is left out andmarginisnullwhen no line has one. - Sales documents require
billTo/shipToon create and carrypublicNotes(client-facing) andnotes(internal), plus an optionalcustomerPo(the client's own purchase-order reference, max 100 characters) that is echoed on every printed, emailed and shared copy. - Orders additionally carry
requestedDate(what the client asked for) andpromisedDate(what you committed to). Both are optional dates, cleared by sendingnull, andpromisedDatedrives the Orders due this week dashboard widget.
Endpoints
Custom fields & bulk actions
| Method | Path | Notes |
|---|---|---|
| GET/POST | /custom-fields |
The fields you added to your own records; entity, includeInactive |
| GET/PUT/DELETE | /custom-fields/{id} |
One definition. The key is fixed once created — the label is not |
| POST | /custom-fields/reorder |
{ entity, ids } |
| POST | /{entity}/bulk |
{ action, ids, payload? } — up to 50 inline (201), more returns 202 with a projectId |
| GET | /bulk-jobs/{id} |
Progress, and each id that failed with the reason |
invoice on orders closes each one out as its own draft invoice, deducting
what has already been paid on it. One per order, never a consolidated bill;
drafts rather than emails. A draft order, or one already invoiced, fails on its
own row.
Clients, items, orders, quotes and invoices accept and return customFields
(a merge: sending one field never clears the others) and filter on
cf.<key> — ?cf.account_no=4471, ?cf.notes=contains:gate,
?cf.visit=after:2026-01-01.
Catalog tree, tags & the archive
| Method | Path | Notes |
|---|---|---|
| GET/POST | /item-categories |
The catalog tree (two levels), and adding to it. Filters: includeInactive, kind |
| GET/PUT/DELETE | /item-categories/{id} |
One category. Delete only when it holds no items |
| POST | /item-categories/{id}/merge |
{ intoId } — its items move, then it goes |
| POST | /item-categories/reorder |
{ parentId?, ids } — one level in its new order |
| GET/POST | /tags |
The workspace's one tag list; search, unusedOnly |
| GET/PUT/DELETE | /tags/{id} |
Rename (rewrites every record carrying it), recolour, or remove |
| POST | /tags/{id}/merge |
{ intoId } — a record carrying both keeps one |
| POST | /{document}/{id}/archive and /restore |
For quotes, orders, invoices, credit notes and purchases |
Those six list endpoints take archived=true|false|all. Deleting a document
past DRAFT returns 409 and names archive instead. Items take
categoryId and filter by it (children included) or uncategorised=true.
Setup checklist, starter kits & opening balances
| Method | Path | Notes |
|---|---|---|
| GET/POST | /setup/checklist |
The eight-step setup checklist. POST { step, done? } ticks one off by hand; POST { dismissed } hides or restores the card |
| GET/PUT | /setup/workspace |
industry and vocabulary — what the workspace makes and the wording it reads in. PUT installs nothing |
| GET | /setup/kit |
Every starter kit on offer and what each installs |
| POST | /setup/kit |
{ industry, withSamples?, vocabulary? } — idempotent; a repeat call adds nothing and returns alreadyInstalled |
| GET | /setup/sample-data |
What a kit installed, and what is in the way of removing it |
| DELETE | /setup/sample-data |
Remove the demonstration records — all or nothing; a 409 lists the blockers |
| POST | /inventory/opening-balances |
What was on the shelf when you opened: one OPENING movement per counted line |
| POST | /invoices/opening-balance |
An unpaid invoice that predates the workspace: never metered, never counted as revenue |
vocabulary changes labels in the app only — no field name here depends on
it, so an integration is never industry-specific.
Webhooks
| Method | Path | Notes |
|---|---|---|
| GET/POST | /webhooks |
POST takes { url, description?, events? }; events is wire names or ["*"]. The response carries secret — the only time it is returned |
| GET/PUT/DELETE | /webhooks/{id} |
isActive: true also clears the failure count |
| POST | /webhooks/{id}/test |
Queue an obviously fake delivery |
| POST | /webhooks/{id}/rotate-secret |
New secret, returned once; the old one stops working immediately |
| GET | /webhooks/{id}/deliveries |
30 days of attempts with the response snippet |
| POST | /webhook-deliveries/{id}/redeliver |
Send a settled one again, as a fresh attempt |
| POST/DELETE | /webhooks/subscriptions |
REST hooks for Zapier: { target_url, event } |
Deliveries are signed X-OrderDen-Signature: t=<unix>,v1=<hmac-sha256(secret, t + "." + body)> and retried after 1 minute, 5 minutes, 30 minutes, 2 hours
and 12 hours. Full details, including the verification code, are in
Webhooks & automation.
Team & access
| Method | Path | Notes |
|---|---|---|
| GET | /me |
What this key is: the workspace, the role and permission flags it carries, the rate it may call at, and seat usage |
| GET | /members |
Everybody in the workspace with their role, the flags in force, whether they use a seat and whether they have a second factor |
| GET/PUT/DELETE | /members/{id} |
PUT takes { role, permissions? }; permissions is a sparse override object, kept only for member and viewer |
| POST | /members/{id}/transfer-ownership |
Owner only: make this member the owner; you become an admin |
| GET/POST | /invitations |
POST takes { email, role?, permissions?, send? } and refreshes rather than duplicating a pending address. send defaults to true, so the API emails the invitee exactly as the app does; false creates it silently. The response always carries url (the link the recipient opens) and emailed |
| DELETE | /invitations/{id} |
Withdraw one nobody accepted |
Nobody grants access they do not hold themselves, and the owner's row is not
editable here. Every role uses a seat, so POST /invitations returns 402 when
the plan's seats are full; a role change never does, because it never changes
what the workspace pays. There is no POST /members: people join by accepting
an invitation.
Organization, dashboard & activity
| Method | Path | Notes |
|---|---|---|
| GET | /organization |
Who the key acts as, with its profile, industry and vocabulary |
| GET/PUT | /organization/profile |
Letterhead (legal name, address, contact, tax ID), baseCurrency, locale, timezone, logo metadata |
| GET/PUT/DELETE | /organization/logo |
The logo file: PUT is multipart/form-data with a logo image (≤ 5 MB) |
| GET | /billing |
Plan, seat prices and subscription, with usage: invoices issued against the lifetime allowance (null = unlimited) — a draft counts for nothing until it is sent, and an issued invoice keeps counting after it is deleted or voided — seats, attachment bytes against the fair-use line, and API calls over the last 30 days. Every price excludes sales tax and VAT, and price is list — priceAfterDiscount and subscription.discount carry a coupon redeemed with a promotion code at checkout. refunds carries the 14-day self-service refund window — the most recent charge, whether it can still be refunded and until when, and the refunds already issued. Refunding is not an API operation: it cancels the subscription, so it lives in Account & Billing |
| GET | /billing/account-credit |
Account credit OrderDen has put on your bill: balance, currency and the ledger of movements. Read-only |
| GET | /dashboard |
Headline numbers |
| GET | /dashboard/widgets |
Widget data; ids, range, from, to |
| GET | /search |
Global search: query, limit |
| GET | /activity |
Activity trail: clientId, entityType, entityId, type, userId (system for entries nobody signed in for), search, from, to |
Reports
| Method | Path | Notes |
|---|---|---|
| GET | /reports/revenue-by-client |
Net revenue, cost and gross margin per client; totals |
| GET | /reports/wholesale-vs-retail |
The period's money split between trade and retail, so the two compare on margin rather than price |
| GET | /reports/revenue-by-item |
The same per catalog item (item-less lines grouped) |
| GET | /reports/revenue-by-period |
Per day / week / month; granularity (auto, day, week, month) |
| GET | /reports/recurring-revenue |
What the running plans are worth a month, annualised, with what each has billed and when the next falls |
| GET | /reports/failed-charges |
Invoices whose card on file was declined, split by whether another attempt is coming |
| GET | /reports/quote-conversion |
What became of the period's quotes, and which option each group's clients picked on the ones you won |
| GET | /reports/backlog-aging |
Open orders by distance from their promised date (snapshot) |
| GET | /reports/fulfilment-cycle-time |
Days from confirmed to fully shipped; summary + per-order rows |
| GET | /reports/on-time-delivery |
Share shipped on or before the promised date; summary + rows |
| GET | /reports/tax-collected |
Tax charged per rate and period on issued invoices; byRate + totals; takes granularity |
| GET | /reports/inventory-valuation |
What the stock was worth as at any date (asAt; omitted = now): quantity, unit cost, value and curing per item and variant, totalled by materials and finished goods |
| GET | /reports/stock-movements |
The stock ledger over the period, newest first; itemId and reason narrow it; totals (in, out, net) |
| GET | /reports/stock-by-location |
A snapshot of what is where: quantity and value per item at every location, with a summary line each |
| GET | /reports/stocktake-variance |
What the counts found, line by line and rolled up per item — which is the table that says where your shrinkage actually is |
| GET | /reports/production-summary |
Runs completed in the period per item: runs, units, materials, labour, overhead and cost per unit, from the costs frozen on each batch |
| GET | /reports/materials-usage |
What production got through per material, with waste on its own line and its share of the total |
| GET | /reports/project-profitability |
Every project in the period: quoted, contracted, cost per bucket, margin and the hours still to bill |
| GET | /reports/expenses-by-category |
Every expense and purchase in the period under each spend category, net of tax, with what is still waiting to be re-billed |
| GET | /reports/profit-and-loss |
Revenue, cost of sales, operating expenses per category and the profit; basis (accrual default, or cash) |
| GET | /reports/cogs |
Cost of goods sold for from–to (default: this fiscal year) in Schedule C Part III shape, every line carrying a drill descriptor, plus the ledger's own answer and the reconciliation between them |
| GET | /reports/schedule-c-expenses |
The period's spend — purchases and expenses alike — grouped by Schedule C expense line, with the categories nothing recognises listed for you to map |
Every report takes range (the dashboard presets; default this_month)
with from/to for range=custom, and ?format=csv (with the
list exports' columns= picker) streams the report's rows. The two tax-time
reports are the exception: they take a plain from/to pair of calendar
dates, because a financial year is a pair of days a filing names. See the
Reports guide and Tax time for what each one
measures.
Clients
| Method | Path | Notes |
|---|---|---|
| GET/POST | /clients |
Filters: search, status, tag, includeArchived, sort |
| GET/PATCH/DELETE | /clients/{id} |
DELETE archives (never hard-deletes) |
| POST | /clients/{id}/restore |
Un-archive |
| GET | /clients/{id}/summary |
Cross-domain rollup |
| POST | /clients/{id}/notes |
{ "message": "…" } → activity |
| POST | /clients/{id}/addresses · /contacts · /contact-methods |
Add sub-records |
| PUT/DELETE | /addresses/{id} · /contacts/{id} |
Replace / remove |
| DELETE | /contact-methods/{id} |
|
| GET | /clients/duplicates |
Candidate duplicates, grouped by shared email then by name + postcode |
| POST | /clients/merge |
{ keepId, mergeIds[], fieldChoices? } — fold duplicates into one |
A merge repoints every row that references a merged client — every
document, payment, address, contact, portal account, project, file and activity
entry — onto keepId in one transaction, applies fieldChoices to the
survivor (tags are combined, credit balances add up), and archives the emptied
records rather than deleting them. Ten at a time at most, and 409 rather than a
half-merge when two of them hold a portal sign-in for the same address. See
Clients.
Items
| Method | Path | Notes |
|---|---|---|
| GET/POST | /items |
Filters: search, type, tag, clientId, includeInactive, lowStock, tracked, sort (adds onHand, available) |
| GET/PATCH/DELETE | /items/{id} |
With clientPrices, variables, costCalcs; trackStock, reorderPoint, reorderQuantity, the shipping fields weightGrams / hsCode / originCountry, careInstructions (printed against the line on the packing slip), and the read-only onHand, allocated, averageCost |
| POST | /items/{id}/archive · /restore |
Deactivate / reactivate |
Stock
| Method | Path | Notes |
|---|---|---|
| GET | /items/{id}/stock |
onHand, allocated, curing, quarantined, available (on hand − allocated − curing − quarantined), short, onOrder, averageCost, value, reorderPoint, reorderQuantity, lowStock, the open alert |
| GET | /items/{id}/movements |
The item's ledger, newest first; filters reason, from, to |
| POST | /items/{id}/stock/adjust |
{ delta, reason, note?, unitCost?, occurredAt? } — a signed quantity; a positive delta with unitCost moves the average |
| POST | /items/{id}/stock/opening |
{ quantity, unitCost?, asOf?, note? } — sets the balance (and turns tracking on); on an item with movements it books the difference and resets the average |
| GET | /items/{id}/stock-by-location |
Where the item's stock is: on hand, allocated and available at each location, plus what is in transit |
| GET | /stock/alerts |
Open low-stock alerts with each item's figures; includeResolved=true adds the history |
| GET/POST | /stock/allocations |
List by ?itemId=, or set stock aside: { orderLineId, quantity, locationId?, note? }. quantity is what the line should end up holding, not a delta, so a retry is safe; 0 releases |
| GET/DELETE | /stock/allocations/{id} |
Read one, or give it back to the shelf |
| GET | /orders/{id}/allocations |
What is set aside for one order |
See Inventory & stock for what the figures mean.
Inventory and bins
Items and inventory are the same rows answering different questions. /items
is the catalogue; these are the shelf.
| Method | Path | Notes |
|---|---|---|
| GET | /inventory |
The shelf, one row per thing that is counted — an item sold in variants gives one row per variant. Filters search (number, name, SKU, barcode or bin), locationId, categoryId, show (low, negative, unbinned, uncounted); ?format=csv streams the lot |
| GET | /inventory/bins |
Every bin recorded, by itemId, locationId or the label |
| PUT | /inventory/bins |
{ itemId, variantId?, locationId?, bins } — an array or a comma-separated string, replacing the whole set at that place; "" clears it. Books no stock movement: a bin is a label |
| GET/PUT | /items/{id}/bins |
The same, with the item in the path |
| GET | /inventory/movements |
The whole workspace's ledger, newest first, with the item and place named |
| GET | /orders/{id}/pick-list |
What is still to go out on an order, in bin order, with what the shelf holds beside each line |
A bin is free text — "A3", "top shelf, left" — and a thing can be in several at one place. Location stays the coarse where (studio, lock-up, van); the bin is the fine one. No bin carries a quantity: the ledger counts stock per location, so bins say where to look, not how many are in each.
See Bins.
Stocktakes
| Method | Path | Notes |
|---|---|---|
| GET/POST | /stocktakes |
The physical count. The scope decides the lines, and what the books say is snapshotted there and then |
| GET | /stocktakes/{id} |
Lines with expected, counted, variance and what the variance is worth |
| PUT | /stocktakes/{id}/lines/{lineId} |
{ counted, reason? } — idempotent, last write wins, several people can count at once |
| GET | /stocktakes/{id}/review |
Variances worst-by-value first, plus what moved since the count began |
| POST | /stocktakes/{id}/post |
Writes one Stock count movement per line that differs, in one transaction |
| POST | /stocktakes/{id}/cancel |
Nothing moved, so nothing is put back |
See Counting stock.
Barcodes & labels
| Method | Path | Notes |
|---|---|---|
| GET | /barcodes/{code} |
What a scanned code is. Barcode, then SKU, then item number — first match wins, variant before parent. ?codes=0 for barcodes only |
| POST | /items/barcodes/generate |
Gives the item number itself to anything without a code. Never overwrites a supplier's |
| POST | /labels |
Barcode labels as a PDF — avery-5160 or thermal-2x1, with skip for the part-used sheet |
Items and variants accept barcode and barcodeType (EAN13, UPCA,
CODE128, QR); leave the type out and it is inferred. A code belongs to
exactly one thing — a duplicate is a 409 naming what already has it.
See Barcodes and labels.
Locations
| Method | Path | Notes |
|---|---|---|
| GET/POST | /locations |
Where stock can be: STUDIO, STORAGE, VAN. Filters: kind, includeInactive |
| GET/PUT/DELETE | /locations/{id} |
The default cannot be switched off or deleted; one holding stock cannot be deleted either |
Orders accept locationId — the shelf the order is picked from. See
Inventory & stock.
Enquiries
| Method | Path | Notes |
|---|---|---|
| GET | /enquiries |
Requests from the public form, newest first; status (NEW, QUOTED, CLOSED) narrows |
| GET | /enquiries/{id} · /files |
One enquiry, and the files sent with it |
| POST | /enquiries/{id}/convert |
{ clientId?, validUntil? } → a draft quote, client matched by email or created |
| POST | /enquiries/{id}/close · /reopen |
{ note? } on close |
The form's switch and sentences are enquiryFormEnabled, enquiryFormIntro
and enquiryFormThanks on /settings/templates. See Enquiries.
Purchase orders from clients
| Method | Path | Notes |
|---|---|---|
| GET | /client-pos |
Purchase orders handed over through the portal, newest first; status (PENDING, ACCEPTED, DECLINED) and clientId narrow |
| GET | /client-pos/{id} · /files |
One submission with reading — the lines read off their document — and the document itself |
| POST | /client-pos/{id}/accept |
A POST /orders body without clientId or status → a draft order |
| POST | /client-pos/{id}/decline |
{ reason }, shown to the client in their portal |
| POST | /client-pos/{id}/read |
Read their document again, after configuring a reader or a failed attempt |
Submitting one is the portal's own, not /api/v1: only a signed-in portal
client can write one. The switch is portalPoEnabled on
/settings/templates, and portalPoEnabled on a client is the per-client
exception. See The client portal.
Quotes
| Method | Path | Notes |
|---|---|---|
| GET/POST | /quotes |
Filters: search, status, clientId, customerPo, dateFrom, dateTo, sort |
| GET/PATCH/DELETE | /quotes/{id} |
Totals recomputed server-side; discountType: null clears the document discount |
| POST | /quotes/{id}/status |
Any status, including Converted; "override": true is required to set or leave Converted (409 otherwise) and is logged |
| POST | /quotes/{id}/convert |
Accepted quote → new order, carrying only the lines the client chose |
| POST | /quotes/{id}/convert-to-invoice |
Accepted quote → new invoice, no order in between. `{ "status"?: "DRAFT" |
| POST | /quotes/{id}/convert-to-project |
Quote → new project, for a job billed more than one way. { "title"?: "..." }. The quote is not consumed — it stays as it is and becomes the project's contract value |
| GET/POST | /quotes/{id}/select-options |
The client's answer: { "lineIds": [...] } is the whole answer, and every option group must end with exactly one line chosen. 409 once accepted or signed |
| POST | /quotes/{id}/save-as-template |
{ name, description? } → a reusable quote template |
Quote lines also accept isOptional, isSelected, optionGroup and
optionGroupLabel; the quote's total counts the selected lines alone and
exposes optionalTotal (what is on offer and not taken, ex tax) and
selectionLockedAt. See
Quotes.
Quote templates
| Method | Path | Notes |
|---|---|---|
| GET/POST | /quote-templates |
Filters: search, includeInactive. Ordered by how often each has been used |
| GET/PUT/DELETE | /quote-templates/{id} |
Sending lines on a PUT replaces them all |
| POST | /quote-templates/{id}/create-quote |
{ clientId, projectId?, billTo?, shipTo? } → a new quote, priced and taxed as of today |
Recurring invoices, cards on file and late fees
| Method | Path | Notes |
|---|---|---|
| GET/POST | /recurring-invoices |
A plan around a draft invoice it copies. Filters: search, status, clientId, includeFinished |
| GET/PUT/DELETE | /recurring-invoices/{id} |
The invoices it raised are untouched by a delete |
| POST | /recurring-invoices/{id}/pause · /resume · /run |
Resume starts at the next step from now; run is idempotent per period |
| GET | /clients/{id}/payment-methods |
Tokens plus brand and last four — never a card number |
| PUT/DELETE | /payment-methods/{id} |
Make it the default, or take it off file (detached at the processor too) |
| POST | /invoices/{id}/charge |
Take what is owed off a card on file. outcome is succeeded, requires_action, failed or nothing_due |
| GET/PUT | /settings/late-fees |
The rule: type, value, grace period and cap |
Saving a payment method is deliberately not an API operation — it needs the processor's own form in front of a person. See Recurring invoices.
Client portal
| Method | Path | Notes |
|---|---|---|
| GET/POST | /clients/{id}/portal-users |
Who can sign in. POST takes { email, name? }; inviting an existing address reactivates it |
| DELETE | /portal-users/{id} |
Access ends immediately, open sessions included. Comments and files stay |
| GET/POST | /quotes/{id}/comments · /orders/… · /invoices/… · /projects/… |
The client conversation on one document. POST takes { body } and posts as your workspace |
| PUT | /attachments/{id} |
{ visibility }: CLIENT shows the file in the portal, INTERNAL hides it |
Signing in is not an API operation: a magic link goes to the client's inbox, which is the point of it. See Client portal.
Statements of account
| Method | Path | Notes |
|---|---|---|
| GET | /clients/{id}/statement |
Computed, never stored. from, to, type (OPEN_ITEM/ACTIVITY) optional — with none it covers the period that just closed |
| POST | /clients/{id}/statement/send |
{ to?, from?, until?, type?, message? } — emails it. Re-sending a period bumps the count rather than writing a second record |
| GET | /statements |
What has been sent, newest first. Filter with clientId |
| GET/PUT | /settings/statements |
The schedule: on/off, frequency, day, what it shows, minimum balance |
Leaving one client off the automatic run is a client field — see Statements of account.
Orders & shipments
| Method | Path | Notes |
|---|---|---|
| GET/POST | /orders |
Same filters as quotes, plus promisedFrom / promisedTo and tags (comma-separated, matches any); every item carries its margin. freeShipping: true holds the client's freight charge at 0 while shipment costs still count against margin. number on create sets your own order number (omitted allocates one) |
| GET/PATCH/DELETE | /orders/{id} |
Includes lines, shipments, invoices and the order's gross margin; number renames the order (409 when taken) |
| POST | /orders/{id}/status |
Any status; by default only the legal transitions are accepted (409 otherwise) and the shipping states are derived — "override": true sets one by hand and logs it |
| POST | /orders/{id}/reopen |
Completed → Confirmed (the only way out of Completed) |
| POST | /orders/{id}/duplicate |
Copy into a new Draft for the same client (fresh number; fulfilment, customer PO, dates and internal notes reset) |
| POST | /orders/{id}/close-short |
{ reason, orderLineIds? } — cancel the open remainder (reason required; omit line ids to close all open lines) |
| POST | /orders/{id}/shipments |
{ carrier?, trackingNumbers?, cost?, locationId?, lines: [{ orderLineId, quantity }] } — carrier must name one of the workspace's carriers (matched case-insensitively; 422 otherwise); cost is the freight actually paid (optional); locationId is where the goods left from, and a void puts them back there (omitted: the order's own location) |
| PATCH | /orders/{id}/shipments/{shipmentId} |
{ cost } — set or correct the freight cost after the fact (null clears it); returns the updated order |
| DELETE | /orders/{id}/shipments/{shipmentId} |
Void; restores quantities, and the stock at the cost it left at. ?restock=false when the goods never came back |
| POST | /orders/{id}/shipments/{shipmentId}/invoice |
Invoice one shipment |
| POST | /orders/{id}/invoice |
Invoice the full order |
| GET | /orders/{id}/packing-slip.pdf |
What goes in the box. ?shipmentId= prints one parcel's slip, carrying only the lines in it |
| POST | /orders/packing-slips |
{ orderIds: [] } → one PDF, one page each, in the order given. Laid out in the background and returned when it is ready; use Print batches below if you would rather not hold the request open |
| POST | /orders/{id}/shipping/rates |
What every carrier would charge — see Shipping labels below |
| POST | /orders/{id}/shipping/labels |
Buy one of those quotes |
| POST | /orders/{id}/shipping/local |
Record a collection or a local delivery instead of a parcel |
Print batches
A stack of documents rendered as one PDF, without holding a request open while it happens. Laying out forty packing slips or merging forty carrier labels is seconds of solid CPU; it runs on the worker and you poll for the file.
| Method | Path | Notes |
|---|---|---|
| POST | /print-batches |
`{ kind: "LABELS" |
| GET | /print-batches/{id} |
PENDING / RUNNING means keep asking; READY means fetch the file; FAILED carries error. printed and missing say how much of what you asked for made it in |
| GET | /print-batches/{id}/file |
The PDF. 404 until READY, and again once the batch has expired (they are kept a few hours) |
An id that cannot be rendered — a shipment with no label bought, an order with
nothing to pack — comes back in missing rather than failing the batch:
thirty-nine slips are better than none.
Shipping labels
Buying postage through the workspace's own EasyPost account. Rating an order is a question, not a write: ask it as often as you like. Buying a label records the shipment with what the postage cost — which is what makes the order's margin the real one — and keeps the PDF.
Millimetres and grams throughout, because that is what the columns hold; money is in the account's own currency.
| Method | Path | Notes |
|---|---|---|
| GET/PUT | /settings/shipping |
The carrier account. connected says whether a key is stored; the key itself is never returned. PUT: { apiKey?, clearApiKey?, defaultFromAddressId?, defaultServicePreference? } — omitting apiKey keeps the stored one |
| GET/POST | /package-presets |
Your boxes. POST: { name, lengthMm, widthMm, heightMm, weightGrams?, carrierPredefined?, isDefault? } — weightGrams is the empty box |
| GET/PUT/DELETE | /package-presets/{id} |
PUT is partial; isDefault: true claims the default from whichever box had it |
| POST | /addresses/{id}/validate |
Check an address against the carrier network. Returns { valid, residential, corrections, messages } — corrections are offered, never applied |
| POST | /orders/{id}/shipping/rates |
Body optional. { presetId?, lengthMm?, widthMm?, heightMm?, weightGrams?, insuranceAmount?, customs?, lines? }. Returns externalShipmentId, every rate priced, the parcel as resolved and needsCustoms. 422 naming the item when something in the box has no weight on file |
| POST | /orders/{id}/shipping/labels |
{ externalShipmentId, rateId, presetId?, …the same parcel fields } — buy one quote. Returns the shipment, carrier, service, amount, tracking number and label attachment id |
| POST | /shipping/labels/batch |
{ orderIds: [], presetId?, servicePreference?, carriers? } — one box, one preference, a label per order. Orders that could not be shipped come back in failed with the reason; the rest are bought |
| GET | /shipments/{id}/label.pdf |
The bought label, for reprinting |
| POST | /shipments/{id}/void |
{ reason? } — ask for the postage back. refunded is what the carrier granted; the fee stays on the order until it is true |
| POST | /orders/{id}/shipping/local |
{ method, pickupReadyAt?, deliveryWindow?, notifyBuyer?, lines? } — LOCAL_PICKUP, LOCAL_DELIVERY or HAND_DELIVERED. No postage, no tracking |
Price lists
Named sheets of prices, with the minimum and case pack each line is sold on. See Wholesale and retail pricing.
| Method | Path | Notes |
|---|---|---|
| GET/POST | /price-lists |
Every list with how many items and clients it covers. kind, includeInactive |
| GET/PUT/DELETE | /price-lists/{id} |
name, kind (RETAIL / WHOLESALE / CUSTOM), currency, isDefault, roundingStyle, markupPercent, isActive. DELETE is refused for the default list and for one a client is priced off |
| GET | /price-lists/{id}/items |
Every sellable thing with what this list charges: unitPrice (null = not on the list), rulePrice, minimumQuantity, casePack, marginPercent. pricedOnly, search, page, pageSize |
| PUT | /price-lists/{id}/items |
Set or clear in bulk: { "items": [{ itemId, variantId?, unitPrice, minimumQuantity?, casePack? }] }. A null unitPrice takes the item off the list |
| POST | /price-lists/{id}/apply-rule |
Fill from the markup rule. markupPercent overrides the list's, overwrite recalculates typed prices, dryRun reports without writing |
| GET | /items/{id}/prices |
What one item costs on every active list, with the margin each leaves and whether the price came from the list's rule |
| GET/PUT | /items/{id}/vendors |
Who sells the item and at what, preferred first; PUT { vendorId, unitPrice?, unitOfPurchase?, conversionFactor?, vendorSku?, isPreferred? } adds or updates one |
| DELETE | /items/{id}/vendors/{vendorId} |
Take a vendor off the item |
| POST | /pricing/resolve |
{ itemId, variantId?, clientId?, priceListId?, quantity? } → the price this buyer pays, which sheet answered (source), and the terms. With quantity, a figure below the minimum or off the case multiple is a 422 saying which |
Clients accept priceListId, and a document line accepts one too
— a one-off trade price on an otherwise retail document. A line left at zero
takes what the lists say; a price you send is kept as sent.
Pricing
What to charge, and what a price actually leaves you. Read-only except for the one write that sets a price, which lands in the item's history saying where the number came from.
| Method | Path | Notes |
|---|---|---|
| GET | /items/{id}/pricing |
Cost breakdown, the suggested retail and wholesale prices from your rules, and what the current price leaves in outcome: what you keep, the margin, the delta to your target and the price that would clear it. Query: variantId, shippingCharged, shippingCost, targetMarginPercent, worstCase=true |
| POST | /items/{id}/pricing |
Set a price: { price, kind?: "RETAIL"|"WHOLESALE", variantId?, fromSuggestion? } |
| POST | /pricing/what-if |
{ itemId, price, variantId?, shippingCharged?, shippingCost?, targetMarginPercent?, worstCase? } — the same answer at a different price. Writes nothing |
| GET/PUT | /settings/pricing |
{ targetMarginPercent, retailMarkupRule, wholesaleMarkupRule, roundingStyle }; a markup rule is { multiplier, addAmount?, rounding? } |
The target is a margin — what is left after the goods and the postage are paid for — not a markup. Postage you charge is revenue and postage you pay is a cost, so absorbing it moves the answer.
Tax rates & groups
The workspace's tax settings (Company Settings → Taxes). A rate is one named
percentage; a group charges one or more of them together, in position
order, and is what a document line points at. Creating a rate also creates the
single-rate group that makes it pickable, so /tax-groups is the list to
read when populating a picker.
| Method | Path | Notes |
|---|---|---|
| GET/POST | /tax-rates |
GET returns active rates; ?includeInactive=true for all. POST: { name, rate, isCompound?, isActive?, sortOrder? } — rate is a percentage (0–100, three decimals) |
| GET/PUT/DELETE | /tax-rates/{id} |
PUT (or PATCH) is partial. Deleting removes the rate and its single-rate group; documents keep the name, rate and money they snapshotted |
| GET/POST | /tax-groups |
GET returns active groups with their rates; ?includeInactive=true, ?includeImplicit=false (hand-built groups only). POST: { name, rateIds: [...], isActive?, sortOrder? } — rateIds is the charge order |
| GET/PUT/DELETE | /tax-groups/{id} |
PUT (or PATCH) is partial; sending rateIds replaces the membership. Single-rate groups are managed through their rate and reject edits and deletes (422) |
On documents. Every line payload takes taxGroupId (omit it to fall
back to the item's default and then the workspace's; send null for no tax)
and taxable (false leaves the line out of tax entirely). Documents take
shippingTaxGroupId for tax on the shipping charge — null, the default,
leaves shipping untaxed. Responses carry taxAmount plus a
taxBreakdown: [{ "name", "rate", "amount" }], one entry per rate the
document charged. Each line carries the taxRate (the group's effective
combined percentage) and taxAmount snapshotted when it was written. A
client with taxExempt: true is never charged, whatever a line asks for.
Carriers
The workspace's shipping-carrier list (Company Settings → Carriers). Shipment.carrier
references a carrier by name; a carrier's trackingUrlTemplate (containing a
{tracking} token) is what turns tracking numbers into links.
| Method | Path | Notes |
|---|---|---|
| GET/POST | /carriers |
GET returns active carriers; ?includeInactive=true for all. POST: { name, trackingUrlTemplate?, isActive?, sortOrder? } |
| GET/PATCH/DELETE | /carriers/{id} |
PATCH is partial; trackingUrlTemplate: null clears the template. Deleting never touches recorded shipments |
Invoices & payments
| Method | Path | Notes |
|---|---|---|
| GET/POST | /invoices |
Same filters as quotes; number on create sets your own invoice number (omitted allocates one). termsText is the printed terms and conditions — omitted snapshots the workspace's invoice terms |
| GET/PATCH/DELETE | /invoices/{id} |
PATCH rejected once Paid/Void; a PATCH on a sent invoice is allowed and the history records it as changed after sending. number renames the invoice (409 when taken); termsText: null clears the printed terms |
| POST | /invoices/{id}/status |
Any status; the payment states normally follow recorded payments, so setting or leaving them needs "override": true. A void releases the project time, expenses and material billed onto the invoice's lines back to unbilled — "releaseBilledWork": false leaves them billed to it |
| POST | /invoices/{id}/payments |
{ amount, number?, method?, methodNote?, reference?, paidAt?, notes? } — method is CASH, CHECK, BANK_TRANSFER, CARD, BANK_DEBIT, MARKETPLACE or OTHER; number sets the receipt number (omitted allocates one) |
| DELETE | /invoices/{id}/payments/{paymentId} |
Recalculates the invoice. Refuses an ONLINE payment — refund it instead |
| POST | /invoices/{id}/payments/{paymentId}/refund |
{ amount?, reason? } — refunds through the processor when the payment was taken online, records it otherwise; may reopen a Paid invoice |
Payments on an invoice carry number (the receipt number), method,
methodNote, source (MANUAL · ONLINE · IMPORTED),
feeAmount and netAmount (the processor's cut and what reached the
workspace; null on manual payments), refundedAmount and their
refunds. An invoice settled in full also carries paidAt — the date of
the payment that cleared it, null on anything unpaid or reopened by a
refund; it is sortable as sort=paidDate and exports as the paidDate
column.
Credit notes, refunds and write-offs
Correcting a sale without pretending it never happened. See Credit notes.
| Method | Path | Notes |
|---|---|---|
| GET | /credit-notes |
Filters: search, status, reason, clientId, invoiceId, outstanding, dateFrom, dateTo, sort, paging. ?format=csv streams the filtered set |
| POST | /credit-notes |
Draft one against an invoice (invoiceId) or free-form (clientId). Lines carry sourceLineId, quantity, unitPrice and restockCondition |
| GET/PUT/DELETE | /credit-notes/{id} |
PUT edits a draft only, and takes line ids the same way a document PATCH does; DELETE voids it |
| POST | /credit-notes/{id}/issue |
It becomes a document the client has. With restock, this is what puts the goods back — per line, by the state they came back in |
| POST | /credit-notes/{id}/apply |
{ invoiceId, amount? } — put credit against an invoice |
| POST | /credit-notes/{id}/refund |
{ method, amount?, reason?, externalId? } — STRIPE goes through the processor; CASH, BANK and OTHER record what you did |
| POST | /credit-notes/{id}/void |
{ reason? } — only while nothing has been applied or refunded |
| GET | /credit-notes/{id}/pdf |
The credit note, themed like your other documents |
| POST | /invoices/{id}/make-recurring |
The plan around a copy of this invoice — its lines go to a new draft the plan bills, and the invoice is untouched. Same body as /recurring-invoices without clientId and templateInvoiceId |
| POST | /invoices/{id}/write-off |
{ amount?, reason? } — record the balance as a bad debt. DELETE reverses it |
| GET | /clients/{id}/credit |
The client's credit balance, the ledger behind it, and whether the two agree |
| GET | /reports/credits-and-refunds |
Per reason, with the bad debt alongside |
| GET | /reports/ar-ageing |
Who owes what, and for how long — a snapshot, per client and per invoice |
| GET | /reports/cash-flow-forecast |
What is expected in over eight weeks: invoices raised, and instalments agreed |
POST /payments takes allocations[] — [{ invoiceId, amount }] — so
one payment can settle several invoices, plus autoAllocate to spread the
rest over the client's open invoices oldest-first. Whatever is left over
becomes their credit. Clients carry creditBalance.
Payment receipts
| Method | Path | Notes |
|---|---|---|
| GET | /payments/{id}/receipt.pdf |
A receipt for one payment, whatever it settled. ?size=thermal for the 80 mm variant |
| POST | /payments/{id}/receipt/email |
{ to, message? } |
Payments as their own collection
The same records read the other way round — every payment in the workspace, not one invoice's.
| Method | Path | Notes |
|---|---|---|
| GET | /payments |
Filters: search (receipt number, reference, note, invoice number, client name), method, source, clientId, invoiceId, dateFrom, dateTo, sort, paging. ?format=csv streams the filtered set |
| POST | /payments |
{ invoiceId, amount, number?, method?, methodNote?, reference?, paidAt?, notes? } — the same as posting to an invoice's payments, with the invoice in the body; returns the payment rather than the invoice |
| GET | /payments/{id} |
One payment with its refunds and the invoice it settles |
Rows carry the invoice they settle, when they settle one, with its number, currency and total, plus that invoice's client. A payment against no invoice is client credit. Deleting and refunding stay on the invoice's own sub-resource above.
Share links & attachments
GET/POST/DELETE /{doc}/{id}/share-link on quotes, orders and invoices —
create (or rotate with { "rotate": true }), read and revoke the
document's portal link.
GET/POST /{parent}/{id}/attachments on clients, quotes, orders,
invoices, purchases and vendors (base64 data, 10 MB cap), plus
GET /attachments/{id},
GET /attachments/{id}/download and DELETE /attachments/{id}.
Two things happen to an upload on the way in. An image is downscaled —
capped at 2,000 px on its long edge and re-encoded, keeping its filename and
its content type; PDFs and anything that is not a photograph pass through
untouched, so the bytes you get back from /download may be smaller than the
bytes you sent. And a workspace has an hourly upload cap on top of the
per-file size limit; going past it returns 409 conflict naming the limit
and when it resets.
Clearing out old attachments
| Method | Path | Notes |
|---|---|---|
| POST | /attachment-purges/preview |
{ "before": "2024-01-01" } → how many files and how many bytes a cutoff would take. Changes nothing |
| POST | /attachment-purges |
Runs it: exports the files in scope to a .zip, verifies the archive, then deletes them. Returns the run |
| GET | /attachment-purges |
Past runs, newest first |
| GET | /attachment-purges/{id} |
One run |
| GET | /attachment-purges/{id}/download |
The archive: every purged file plus a manifest.json |
| DELETE | /attachment-purges/{id} |
Deletes the archive from OrderDen storage — the step that actually frees the space. The run record stays |
Only attachments are removed. The client, quote, order, invoice, purchase, expense or project each file hung off is untouched. Catalog item photographs and the organization logo are never in scope, the cutoff must be at least 30 days ago, and one run packages at most 5,000 files or 512 MB, whichever comes first. Owners and admins only. See Your data.
Variants, kits & item images
| Method | Path | Notes |
|---|---|---|
| GET/POST | /items/{id}/variants |
The variant grid. POST replaces it: a row with an id keeps its stock and history, one left out is removed (or archived once it has been sold), and an empty list makes the item a single thing again |
| POST | /items/{id}/variants/generate |
Propose every combination with a suggested SKU. Writes nothing |
| GET/PUT/DELETE | /variants/{id} |
A null price or cost inherits the product's; DELETE is 409 once the variant has been sold |
| GET/PUT | /items/{id}/components |
What a kit contains, with what it can supply and what it costs |
| GET/POST/PUT | /items/{id}/images |
List, upload (multipart/form-data, a 256 px copy stored beside it) and reorder — the first is the thumbnail |
| DELETE | /items/{id}/images/{attachmentId} |
Document lines accept variantId and personalization; a line naming an
item with variants must carry one (422 otherwise). Stock lives on the variant,
so the adjust and opening-stock calls take a variantId too — see
Items & pricing.
Materials, units, variables & recipes
The costing side. Materials are catalog items of type MATERIAL, so every
/items endpoint works on them too; these routes scope the list and force
the type. See Materials, recipes and what things cost.
| Method | Path | Notes |
|---|---|---|
| GET/POST | /materials |
Same filters, body and CSV export as /items; the type is forced |
| GET/PUT/DELETE | /materials/{id} |
404 when the item is not a material |
| GET/POST | /units |
Filters: dimension, customOnly. factorToBase is how many base units one of it is (gram, millilitre, millimetre, each, minute) |
| GET/PUT/DELETE | /units/{id} |
DELETE is 409 while anything still counts in it |
| GET/POST | /variables |
The workspace's shared numbers; the defaults are created on first use |
| GET/PUT/DELETE | /variables/{id} |
|
| GET/PUT/DELETE | /items/{id}/recipe |
The bill of materials: lines of { materialItemId, quantity, unitId?, wastePercent? }, plus batchYield, labourMinutes and overhead |
| GET | /items/{id}/recipe/cost |
Materials, labour, overhead and the cost of one unit, with the margin and the materials nothing has costed yet |
| POST | /items/{id}/recipe/adopt |
"Use recipe cost as unit cost" |
Items carry unitId, costSource (MANUAL/RECIPE/PURCHASES)
and the read-only recipeUnitCost; GET /items?salesOnly=true leaves
materials out.
Production runs
Making stock rather than buying it. A run scales the item's recipe to the quantity being made; nothing moves on the stock ledger until it completes, which is what makes a planned run free to cancel. Completing consumes the materials, books any waste as an adjustment, puts the finished batch on the shelf at what it cost, and freezes that cost so a later price change never rewrites it. See Production.
| Method | Path | Notes |
|---|---|---|
| GET/POST | /production-runs |
Filters: status, itemId, orderLineId, curing, search, from, to. POST takes { itemId, variantId?, quantity, batchCode?, labourMinutes?, notes?, startedAt?, orderLineId?, lines? } — omit lines to take them from the recipe |
| GET/PUT/DELETE | /production-runs/{id} |
PUT and DELETE only while the run is planned or in progress |
| POST | /production-runs/{id}/start |
Planned → in progress; still nothing moves |
| POST | /production-runs/{id}/complete |
{ quantityProduced?, actualLines?, labourMinutes?, cureUntil?, completedAt? } — moves the stock and freezes the cost; returns warnings for anything it took below zero |
| POST | /production-runs/{id}/cancel |
{ reason? }; nothing moved, so nothing is put back |
| GET | /items/{id}/buildable |
How many whole units the material on hand allows, and which material runs out first |
Items carry madeToOrder (confirming an order opens a planned run against
each of its lines) and curingDays (how long a finished batch sits before it
can be sold — curing stock is on hand but not available).
Vendors & purchases
| Method | Path | Notes |
|---|---|---|
| GET/POST | /vendors |
Filters: search, tag, includeInactive, sort |
| GET/PATCH/DELETE | /vendors/{id} |
With purchase rollups |
| POST | /vendors/{id}/archive · /restore |
|
| GET/POST | /purchases |
Filters: search, status, vendorId, categoryId, awaitingReceipt, dates, sort. number on create sets your own purchase number (omitted allocates one) |
| GET/PATCH/DELETE | /purchases/{id} |
number renames the purchase (409 when taken) |
| GET/POST | /purchases/{id}/receipts |
Book a delivery: { receivedAt?, note?, lines?: [{ purchaseLineId, quantity }] }; omitting lines receives everything outstanding. GET returns the deliveries plus each line's outstanding and landed cost |
| DELETE | /purchases/{id}/receipts/{receiptId} |
Undo a delivery, reversing the stock it added |
| POST | /purchases/{id}/email |
Email the PO to the supplier (to, message); omitting to uses the vendor's address |
| POST | /purchases/{id}/status · /receive · /payments |
|
| POST | /purchases/reorder |
Draft purchases from low stock: { alertIds?, itemIds?, allOpenAlerts? } → one draft per preferred vendor at the remembered prices, plus needsVendor for items with no preferred vendor |
| DELETE | /purchases/{id}/payments/{paymentId} |
Purchase lines accept itemId (what the line buys), unitOfPurchase
and conversionFactor; the header accepts allocateExtraCosts.
Receiving a line that names a stock-tracked item adds
quantity × conversionFactor to stock at its landed cost, which moves the
item's average — see Vendors & purchasing.
A purchase's spend category is categoryId, from the same shared list
expenses use.
Spending
| Method | Path | Notes |
|---|---|---|
| GET | /spending |
Purchases and expenses in one list. Each row carries kind and movesStock. Filters kind, search, vendorId, categoryId, projectId, dateFrom, dateTo; ?format=csv streams the lot |
Read-only. The two records stay two records: receiving a purchase writes a
receipt, a stock movement and a new average cost, and an expense never touches
stock — so creating and editing either still goes to /purchases or
/expenses.
See Spending.
Projects & time
| Method | Path | Notes |
|---|---|---|
| GET/POST | /projects |
Filters: search, status, clientId, tag, assignedUserId, openOnly, marginBelow, dates, sort. Project numbers are never metered |
| GET/PUT/DELETE | /projects/{id} |
DELETE is refused while issued invoices point at it |
| GET | /projects/{id}/profitability |
Quoted, contracted, invoiced, cost per bucket, margin and the variance — from the rows, not the cache |
| GET/POST | /projects/{id}/time-entries |
minutes is what is logged; the burdened cost is frozen onto the entry |
| POST | /projects/{id}/bill |
{ invoiceId?, entryIds?, expenseIds?, stockKeys?, grouping? } — time, expenses and material in one call; omit invoiceId to raise the draft |
| POST | /projects/{id}/bill-time |
{ invoiceId, entryIds?, grouping? } — writes LABOUR lines and stamps each entry |
| POST | /projects/{id}/bill-expenses |
{ invoiceId, expenseIds? } |
| GET/POST | /projects/{id}/bill-stock |
Material not yet charged, netted per item. POST { invoiceId, keys? } writes MATERIAL lines at cost plus your markup |
| GET/PUT/DELETE | /time-entries/{id} |
Refused once the entry has been invoiced |
Quotes, orders, invoices, purchases and expenses accept a projectId, and
document lines accept a kind (ITEM, LABOUR, MATERIAL,
EXPENSE, TEXT). See Projects.
Expenses
| Method | Path | Notes |
|---|---|---|
| GET/POST | /expenses |
Filters: search, categoryId, vendorId, orderId, projectId, billable, unbilled, recurringOnly, dates, sort. amount and categoryId are the only required fields on create |
| GET/PUT/DELETE | /expenses/{id} |
DELETE is refused (409) once the expense has been re-billed |
| GET/POST | /expenses/{id}/attachments |
The receipt photographs; POST takes JSON (base64) or a multipart file |
| POST | /expenses/{id}/rebill |
{ invoiceId, markupPercent?, description? } — adds the line and records which line it went out on |
| GET/POST | /expense-categories |
The shared spend list; includeInactive shows hidden ones |
| GET/PUT/DELETE | /expense-categories/{id} |
DELETE is refused while anything is filed under it |
| POST | /expense-categories/{id}/merge |
{ intoId } — moves every record across, then removes the source |
| POST | /expenses/from-image | A photograph in, a drafted expense out (multipart file, or base64 JSON) |
| POST | /expenses/{id}/confirm | Agree with the draft; an empty body confirms it as read |
| GET/PUT | /settings/inbound-email | The workspace's receipts-in address; { enabled: false } silences it |
| POST | /settings/inbound-email/rotate | Replace it — the old one stops accepting mail |
A drafted expense is a suggestion: confirmedAt is what a person set, and
needsReview=true lists the drafts nobody has agreed with yet. Spend is
counted net of the tax on it (amount - taxAmount), and a
re-billed line is priced at that net figure plus markupPercent. See
Expenses.
Accounting export
| Method | Path | Notes |
|---|---|---|
| GET/PUT | /accounting/map |
The chart-of-accounts mapping for ?provider= QBO, XERO or GENERIC; PUT replaces it wholesale |
| GET/POST | /accounting/exports |
{ provider, from, to, acceptSuspense?, lockPeriod? } generates and stores one |
| GET | /accounting/exports/{id} |
One export with its documentCounts |
| GET | /accounting/exports/{id}/download |
The stored zip, byte for byte as generated |
| GET | /accounting/changed-since-export |
What has been edited inside a period already handed over |
The zip holds sales, credits, payments, bills,
expenses, fees and journal CSVs. The journal balances every day
in the period — debits equal credits, checked before anything is written.
Generating refuses with 422 unmapped_accounts while anything has no
account, listing what is missing; acceptSuspense: true generates anyway
into account 9999. See handing the numbers to your accountant.
Imports (CSV)
Everything the import wizard does. A file arrives either as
multipart/form-data with a file part (plus kind, and optional
mapping as a JSON part and duplicatePolicy) or as JSON with the CSV
in csv — or as already-parsed rows of column → value, for a script
with no file to hand. Omit mapping and the columns are matched by their
headings, exactly as the wizard pre-fills them.
| Method | Path | Notes |
|---|---|---|
| POST | /imports/preview |
Parse and validate without writing: returns the suggested mapping, a sample value per column, the kind's fields, the first 20 rows with per-cell errors, every matched row in duplicates, and counts (rowCount, validCount, errorCount, duplicateCount, newCount) |
| POST | /imports |
Run it. kind is one of the ten below; duplicatePolicy is skip (default), update or create. Returns the batch |
| GET | /imports |
Import history, newest first; filter by kind |
| GET | /imports/{id} |
One batch, with up to 200 rejected rows as { row, column, message } |
| POST | /imports/archive |
Load a full-export zip back in — multipart/form-data with a file part, or JSON with the zip base64-encoded in archive. Add dryRun to be told what would happen and write nothing. Returns the report, kind by kind |
| POST | /imports/{id}/undo |
Archive what the run created. Records already used on a document are kept and reported as blockedCount; 409 when every one is in use, or the batch was already undone |
The ten kinds, roughly in the order to do them: clients, items,
materials, vendors, price-list, opening-stock,
orders, invoices, payments, expenses. (open-invoices is the old
name for invoices and still works.) A preview returns the
kind's fields — name, label, type, required, an example and a line of
help — so a script can build a file without hard-coding column names.
Rows are created through the same services as the rest of the API, so
validation, numbering and the activity trail are identical; a rejected row
never stops the run. Each batch reports createdCount, updatedCount,
skippedCount and errorCount, and keeps createdIds — the undo
list. Undo covers created records only; rows that updated an existing record
are not rewound.
Two behaviours are worth knowing before you script against it.
opening-stock creates nothing — it sets balances, so re-importing a
corrected count books the difference rather than doubling the shelf. And
imported history is never counted: opening-balance invoices do not touch
the free 50, because a migration is not selling you did here.
Payment terms, deposits and schedules
GET/POST /payment-terms and GET/PUT/DELETE /payment-terms/{id} manage
the workspace's named terms. A term says what is wanted up front — a
percentage or a fixed amount, never both — and when the balance falls due
(ON_INVOICE, NET_DAYS, ON_DELIVERY or ON_COMPLETION; the last two
are events, so an invoice raised under them starts without a due date).
Quotes accept paymentTermId, depositRequired, depositPercent and
depositFixed, and report depositAmount. Accepting a quote raises a
DEPOSIT invoice for it, idempotently.
GET/PUT /orders/{id}/schedule reads and replaces the instalments agreed for
a project. A schedule made entirely of percentages must add up to 100; mix in a
fixed amount and that check stops applying. A step already invoiced cannot be
removed or re-priced. POST /orders/{id}/schedule/{stepId}/invoice turns a
stage into its own PROGRESS invoice. The money a schedule reports —
contractTotal, scheduled, invoiced, remainingToInvoice and each
step's amount and value — comes back as decimal strings like every other
amount; percent stays a number.
POST /orders/{id}/prepayment-invoice takes money for an order before
anybody has billed for it: a DEPOSIT invoice for what is left of the order,
which the final invoice later deducts rather than billing twice. Idempotent on
the order, and what the portal's pay button on an order link calls.
POST /orders/{id}/final-invoice closes the project out: the whole contract, less
every deposit and progress payment already paid on it, deducted as its own
negative line and never taxed again. Invoices expose kind,
appliedDeposits and balance.
POST /invoices/combined takes { orderIds, status? } and closes several of
one client's orders out on a single invoice — the trade account fulfilled
five times in a week that would rather pay once than chase five references. Each
order's lines, shipping and paid deposits are worked out on their own and then
added up, so the combined bill charges exactly what the separate ones would
have. order is null on it and billedOrders names each order with its own
share of the total; those shares are what each order's invoiced figure
counts, so five orders on one bill are attributed once each rather than five
times over.
It refuses (422) where the orders cannot share a document, naming them: a whole-document discount (a discount on the lines is fine), a payment schedule, different projects, different currencies, different tax-inclusive pricing, or shipping at two different tax groups. A combined invoice counts as one against the Free plan's lifetime fifty, because it is one invoice raised.
For a whole list at once, POST /orders/bulk with action: "invoice" and
payload.combine: true does the same per client: a client with one order in
the selection gets an ordinary invoice, and a group that cannot be combined
fails every order in it with the reason while the other clients' groups go
through.
See Deposits, instalments and final invoices.
Signatures on quotes
Quotes accept requireSignature and termsText (Markdown, snapshotted
onto the quote) and expose the signature they carry.
GET /quotes/{id}/signature reads it; POST /quotes/{id}/signature
records one taken in person — signerName, signerEmail, method
(TYPED or DRAWN), a base64 PNG image for a drawn one, and
consented: true. The hash is never taken from the request: the server
fingerprints the quote as it stands, so a signature always points at what the
workspace actually has. One document, one signature — a second is a 409.
The fingerprint is over the document's content (number, client, every line, the money, the terms), not the PDF bytes, so re-rendering never invalidates it and a real edit always does.
Changing what was agreed means editing the order the quote became — PUT /orders/{id} moves its lines, its money and the tax on them together, and the
order's history records what changed.
See Signatures on quotes.
Reminders, follow-ups and email templates
GET/PUT /settings/reminders reads and replaces the chasing schedule: the
steps for invoices and quotes, the send hour (local to the workspace),
skipWeekends, the master pauseAll, and the daily summary's hour. A step
is { key, offsetDays, enabled, templateKey }; its key is permanent,
because the reminder log points at it forever.
GET /email-templates lists every letter the app sends on your behalf;
GET/PUT/DELETE /email-templates/{key} reads, replaces and resets one, and
POST /email-templates/{key}/test posts you a specimen filled in with a
sample client and invoice. The body is Markdown with {{…}} variables; one
the document does not carry renders blank.
GET/PUT /invoices/{id}/reminders and GET/PUT /quotes/{id}/follow-ups
report when the next one is due and everything already sent, and pause chasing
on that one document. POST .../reminders/send and
POST .../follow-ups/send send one immediately — recorded under its own
manual-… key, so a scheduled step is never consumed by it.
Every send is recorded against (document, step), so a client is never
chased twice for the same step however many times the worker runs. Chasing
stops by itself when the invoice is settled or the quote answered.
See Reminders, follow-ups and status emails.
Push notifications
| Method | Path | Notes |
|---|---|---|
| POST/DELETE | /me/push-subscriptions |
Subscribe or unsubscribe this browser for web push. Session-authenticated, not API-key: a subscription belongs to the person at the browser, not to a workspace |
Notifications
GET /notifications?userId=… is one member's bell menu —
{ items: [{ id, kind, title, body, href, readAt, createdAt }], unread },
newest first, with unreadOnly and limit — and
PUT /notifications/{id}/read with { userId } marks a row read (or
unread with read: false).
Settings
GET/PUT /settings/notifications reads and saves what reaches one member,
in the app and by email — preferences are per member and a key acts as the
whole workspace, so userId names the member (a query parameter on GET, in
the body on PUT): { userId, preferences: [{ kind, email, inApp }] }. A
kind left out keeps its value.
GET/PUT /settings/templates (document design, its major and minor accent
colours, footer — plus quoteFooter, invoiceFooter and purchaseFooter,
each blank meaning the shared one — the terms new documents start with:
defaultTermsText (quotes), invoiceTermsText (blank prints the quote
terms) and purchaseTermsText (blank prints nothing), payment instructions, the
number formats for quotes, orders, invoices, purchases and payments, orderLeadTimeDays — the default lead time a new order's
promised date starts on — and the packing-slip fields: packingSlipThankYou
(Markdown with {{client.firstName}}, {{order.number}}, {{org.name}}
and {{org.website}}), packingSlipShowPrices,
packingSlipShowGiftMessage, packingSlipSize
(A4/LETTER/LABEL_4X6), packingSlipSocialHandle and
packingSlipReviewUrl), GET /settings/templates/designs (the fifteen
designs and the two colour palettes the pickers offer, so you can discover a
valid themeId rather than guessing one), GET/PUT /settings/sales (minimumMarginPercent — the
gross-margin floor that flags an order on confirm; null clears it, and
leaving it out of a save keeps it — defaultTaxGroupId, the tax a new line falls back to,
pricesIncludeTax (prices are quoted with the tax already in them; each document keeps the mode it was raised under), blockNegativeStock —
which makes being short of a material refuse a production run rather than
warn about it — quoteFlow — whether an accepted quote becomes an order,
an invoice, or a question each time — and the three
autoEmailOnConfirm / autoEmailOnShip / autoEmailOnDeliver switches
for the customer-facing order status emails) and
GET/PUT/DELETE /settings/mail (your own SMTP server, or null while
platform delivery is in use; the password is never returned, and
signature and bccAll apply to every templated email).
GET/POST /suppressions and DELETE /suppressions/{id} are the addresses
OrderDen has stopped emailing, because they bounced permanently, somebody
reported the mail as spam, or they bounced temporarily three times inside 90
days with nothing getting through in between (SOFT_BOUNCE). Sending to one
fails naming the address rather than
disappearing into the provider. A single temporary bounce never lands here, and
a successful delivery resets the count. Clearing
one also clears it at the mail provider, whose own list is account-wide and
would otherwise keep rejecting the address for a reason you cannot see. Saved SMTP
settings only take over from OrderDen's own delivery once verified: POST /settings/mail/verify with { "to": … }
sends a test email through them and marks them verified (verifiedAt).
GET/PUT /settings/payments reads and updates the connected payment
account — accountId, onboardingComplete, chargesEnabled,
payoutsEnabled, canAcceptPayments, and the acceptCards /
acceptBankDebit / allowPartialOnlinePayment toggles.
POST /settings/payments/connect returns a single-use hosted onboarding
url to open in a browser (creating the account on first call); optional
returnUrl / refreshUrl override where the processor sends the user
back to. See Getting paid online.
Your data (full export)
GET /export streams a .zip of everything the workspace holds:
CSV spreadsheets of every list, JSON of the complete records (lines, tax,
shipments, payments, contacts, settings, the activity trail) and every
uploaded file as it was uploaded. Secrets — SMTP passwords and API keys — are
deliberately excluded. It is limited to one every 30 days;
GET /export?check=1 reports { available, lastExportAt, nextAvailableAt }
without building anything, and a 409 names the next available date.
Individual lists still export as CSV any time with ?format=csv. See
Your data.
Worked example
BASE=https://your-domain.example/api/v1
AUTH="Authorization: Bearer $ORDERDEN_API_KEY"
# Create a client, quote them, accept, convert to an order
CLIENT_ID=$(curl -s -X POST $BASE/clients -H "$AUTH" -H "Content-Type: application/json" \
-d '{"name": "Jane Doe", "companyName": "Acme"}' | jq -r .data.id)
QUOTE_ID=$(curl -s -X POST $BASE/quotes -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"clientId\": \"$CLIENT_ID\", \"billTo\": \"Acme, 1 Main St\", \"shipTo\": \"Acme, 1 Main St\", \"lines\": [{\"description\": \"Widget\", \"quantity\": 3, \"unitPrice\": 25}]}" | jq -r .data.id)
curl -s -X POST $BASE/quotes/$QUOTE_ID/status -H "$AUTH" -H "Content-Type: application/json" -d '{"status": "ACCEPTED"}' > /dev/null
ORDER_ID=$(curl -s -X POST $BASE/quotes/$QUOTE_ID/convert -H "$AUTH" | jq -r .data.id)
# Invoice it and record the payment
INVOICE=$(curl -s -X POST $BASE/orders/$ORDER_ID/invoice -H "$AUTH")
INVOICE_ID=$(echo "$INVOICE" | jq -r .data.id)
curl -s -X POST $BASE/invoices/$INVOICE_ID/payments -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"amount\": $(echo "$INVOICE" | jq -r .data.total), \"method\": \"card\"}"
For AI assistants
/llms.txt is a map of this API and the guides, in the format an
assistant looks for before it starts guessing; /llms-full.txt
is every endpoint and event in one file, for one with room to read it.
There is also an MCP server, so Claude, Cursor and anything else that speaks the protocol can work in your workspace directly:
{
"mcpServers": {
"orderden": {
"command": "npx",
"args": ["orderden-mcp"],
"env": { "ORDERDEN_API_KEY": "od_live_…" }
}
}
}
It is an ordinary API client, so it gets the permissions of the key you give it, and the same per-minute rate limit. Give it a key made by an account whose access matches what you want it doing — a key that cannot see costs gives it an assistant that cannot see costs.
Everything on this page is in the free tier — one person, the whole product, no card.
Start free