OrderDen is pre-release software. There is no paid plan yet, no support model, and no guarantee your data will be preserved. Read this before you put your business in it or tell us what broke.

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=field or ?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.limit is invoices or seats), 403 forbidden (the key's permissions don't cover this; details.permission names 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 newest updatedAt you 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-Key header (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-Remaining and X-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 use PATCH. Document numbers are server-assigned from your configured formats when number is omitted; quotes, orders, invoices, purchases and payments all accept one of your own on create, and PATCH renames it (409 when taken). Derived statuses (fulfilment, payment states) can't be set directly.
  • Lines: lines may 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 a PATCH sends lines, send those ids back with them: a line carrying its id is 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. An id that is not a line on that document, or the same one sent twice, is rejected 422; id is ignored on POST, so a document read back and posted as a copy needs nothing stripped.
  • Discounts: documents and their lines take an optional discountType (PERCENT or AMOUNT) with a discountValue — both or neither; discountType: null on PATCH clears it. Line discounts come off quantity × unitPrice, the document discount off subtotal before tax, and responses add the resolved discountAmount at 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 (null when unknown), snapshotted on create and whenever lines are replaced, and carried unchanged through convert, invoice and duplicate. A line with no itemId may set unitCost explicitly; 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 plus shippingAmount, tax excluded) against the cost basis (each line's quantity × unitCost plus every recorded shipment cost), as margin and marginPercent, per line under lines, with coverage saying how many lines and shipments have a cost. A line or shipment with no cost is unknown, never zero: it is left out and margin is null when no line has one.
  • Sales documents require billTo/shipTo on create and carry publicNotes (client-facing) and notes (internal), plus an optional customerPo (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) and promisedDate (what you committed to). Both are optional dates, cleared by sending null, and promisedDate drives 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 fromto (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

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.

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

See OrderDen on your phone.

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