Put your shop's sales data
where the work happens.
Create and read sales orders and quotes, customers, the supporting product catalogue, inventory levels and suppliers. Every request is authenticated with a bearer token and scoped to the company that issued it. No SDK required — it is JSON over HTTPS.
Base URL
One host, one major version in the path.
https://api.factory.app/v1
What v1 covers
What v1 does not do yet
Worth knowing before you design around it. None of these are permanent.
updatedSince checkpoint.Retry-After. Concrete numbers are coming — back off and retry.Quickstart
Four calls: prove the key works, resolve a customer, find something to sell, then write an order. Everything below runs against your live company, so start on an account you do not mind adding a draft order to.
Create a key in the Factory app
Open Settings, then API keys, and create one. It is shown once. A key is scoped to the company that made it and carries that company's permissions, so treat it like a password: server side only, never in a browser or a mobile app.
FACTORY_API_KEY in your environment. Every sample on this site reads it from there.Confirm the key and read your settings
A singleton read keyed by the token. It is the cheapest way to prove authentication works, and it tells you the currency, tax rate and measurement system every later response is expressed in.
curl https://api.factory.app/v1/company \ -H "Authorization: Bearer $FACTORY_API_KEY"
Resolve a customer and a product
An order line references ids, not names. Resolve both before you write. Catalogue search takes one text query and returns ranked hits across products, kits and flashings.
Write a draft order
Send the whole order in one call. Leave isSubmitted off while you are testing and it lands as a draft. Always send a requestId (a UUID you generate) — a repeat with the same key returns the original order instead of creating a second one.
curl -X POST https://api.factory.app/v1/orders \ -H "Authorization: Bearer $FACTORY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "requestId": "d5f7a8c2-3e1b-4d9f-a6c0-8b2e4f1d7a3c", "customerId": "cust_01k2m4p6r8t0v2x4z6a8c0e2g4", "reference": "PO-88431", "lines": [ { "catalogue": { "productId": "prod_01k2m4p6r8t0v2x4z6a8c0e2g4", "rowPriceId": "rowprice_01k2m4p6r8t0v2x4z6a8c0e2g4", "pricing": { "quantity": "12" } } } ] }'
Authentication
One scheme: a bearer token in the Authorization header. There is no OAuth flow, no request signing, and no query-string key.
Authorization: Bearer fk_live_9Qb2vX7mR4tE1sYw8Kp3Dn6Lz0Hc5Ja
Scope
A token identifies exactly one company. Every list is filtered to that company and every write lands inside it — you never pass a company id. Asking for a resource that belongs to someone else returns 404, not 403, so an id from another account is indistinguishable from one that does not exist.
Administrator-only reads
A few reads check the caller's role rather than just the company. GET /v1/users/{userId} is administrator-only and returns 403 otherwise. GET /v1/users works for everyone but omits email addresses unless the caller is an administrator — so an integration that needs emails needs an administrator's key.
When it fails
Handling keys
- Server side only. A key in client code is a key you have published.
- One key per integration, so you can revoke one without breaking the rest.
- Rotate by creating the new key, deploying, then deleting the old one. Both work during the overlap.
- A deleted key stops working immediately and every call with it returns 401.
Core concepts
The things that will bite you if you skim. Read this once and the rest of the reference is obvious.
Money is micros
A Money object is an integer count of millionths of the currency's major unit, plus an ISO 4217 code. One dollar is 1,000,000 micros. There are no floats anywhere in pricing, and the micro count travels as a string.
{ "amountMicros": "12340000", "currency": "AUD" } // $12.34
Divide by 1,000,000 for display, never for arithmetic. Do the maths in micros with integers and format at the edge.
Every 64-bit number is a string
Including amountMicros and purchaseOrder. JavaScript loses precision above 2^53, so we never send those as JSON numbers. Quantities, discounts, percentages and markups are decimal strings too — a quantity of "2.5", a discount of "10.00" meaning ten percent.
Ids carry their type
Every id is a prefix, an underscore, and a 26-character sortable suffix. The prefix tells you what you are holding, and sending the wrong kind is rejected rather than silently mis-resolved.
quote_ABC… becomes order_ABC…. That is how you follow one document across both feeds.Enums travel as names
Responses always return the upper-case name — FULFILMENT_METHOD_DELIVERY, not 2. Integers are accepted on the way in, but send names. New values can appear inside v1, so treat an unrecognised name as unknown rather than crashing on it.
Pagination
Every list takes pageSize and pageToken and returns nextPageToken. Loop until it comes back empty. Tokens are opaque, may be bound to the query that minted them, and should never be stored or constructed.
Idempotency
On CreateOrder and CreateQuote, send a client-generated UUID in requestId. A retry with the same key returns the original document. Reuse the key with a materially different body and you get 409. Keys are retained for at least 24 hours.
CreateCustomer validates requestId but does not deduplicate on it yet. Retrying a create that already succeeded fails on the duplicate company name instead — resolve it by listing with the companyName filter and taking the existing id.Archiving
An archived document is hidden from every read by default, and stays read-only. Each endpoint opts back in differently, which is the part worth reading twice.
Every document read through one of those flags carries isArchived: true. On a default read it is always false. Changing the flag partway through pagination invalidates your page token, so start the walk again.
Labels
A label is a small company-scoped tag — name and hex colour — that can sit on any number of quotes and orders. They are created and renamed in the Factory app; the API reads the list and sets which ones are attached. A rename keeps the id, so store the id and not the name.
PUT /v1/orders/{orderId}/labels is not an append. The list you send becomes the complete set — labels you leave out are removed, and an empty list clears every one. Read the order first if you mean to add to what is already there. An unknown label id fails the whole call with 404 and changes nothing.You can also attach labels at creation time by passing labelIds on CreateOrder or CreateQuote, which saves a second call. Same validation: an unknown id fails the whole request with 404, before the document is created.
SetOrderLabels.To find documents by label, pass labelIds to ListOrders or ListQuotes. It matches any of the ids you give rather than all of them, and combines with the other filters. Changing it mid-pagination invalidates your page token, same as the archive flag.
Labels come back on a full read of a document, oldest attachment first. They are absent from line-write responses, so read the document if you need to see them.
Inventory is per-variant
A stock entry is the position of one product variant row at one colour, never a product total. A product with several rows appears once per row — which is why a single-row product can look like a product total — and a colour-tracked product splits each row again, one entry per colour. Sum a product's entries when you need the whole product's position. Each entry names its variant: thickness and the same attributes pairs the catalogue row holds, so entries are tellable apart without a catalogue read.
available is onHand minus promised and goes negative when more is committed than is on hand. All four quantities are decimal strings. onHand is the only value you can set — promised and onTheWay are computed from open orders and purchase orders, and SetStockLevels echoes the full recomputed set back.
productRowId on a stock entry is the same id the catalogue product's rows[].productRowId carries — join there when you need the variant's prices or colour options. colour is populated only for colour-tracked products; all colour splits of a row share its thickness and attributes but carry their own quantities.Versioning
The major version is in the path. Inside v1 changes are additive: new fields and new endpoints can appear at any time, so your client must tolerate unknown fields rather than reject them. Breaking changes only ever ship under a new version path, and deprecations are announced ahead of time.
Field filtering
Reduce response payload size by requesting only the fields you need, using the camelCase JSON field names you already see in responses. Every endpoint supports them: as query parameters on reads, and as request body fields on creates, line adds, label writes and drawing uploads.
Include fields
Add fields to the query string with a comma-separated list:
Envelope keys such as nextPageToken are always preserved — paths are relative to the resource, not the envelope.
Exclude fields
Use excludeFields to drop specific fields and keep everything else:
Nested fields
Use dot notation to reach inside nested objects:
Paths across arrays
A path that crosses an array is applied to every element. labels.name keeps name on every label and drops the rest of each one:
Detail endpoints
Filtering works identically on single-resource GETs. You do not need to name the wrapper key:
Write operations
Creates, line adds, label writes and drawing uploads take the same two options, but as fields on the request body rather than in the query string. A ?fields= query parameter on these endpoints is ignored:
Rules
- Field names are camelCase — the same names you see in responses.
- Dot paths map across arrays.
labels.namekeepsnameon every element oflabels. - If both
fieldsandexcludeFieldsare supplied,fieldswins and the other is ignored. It is not an error. - Naming a field that does not exist on the resource is a no-op — silently omitted, not rejected.
- Filtering applies to
2xxJSON responses only. Error bodies are never filtered.
Errors
Every error response carries the same envelope, whatever the status: a short stable type naming the kind of failure, a human-readable message, and your echoed request id. On validation failures, errors lists one entry per field-level problem; on every other kind of failure it is empty.
Reading it
typeis the first thing to branch on —validation_failure,not_found,failed_precondition,conflict,rate_limited. Bothvalidation_failureandfailed_preconditionare HTTP 400, but the first means bad input and the second means the document is in a state that forbids the change. Treat an unrecognised type as a generic error.parampoints at the offending field in snake_case, indexed into arrays. Map it back to your own line numbers so an operator sees which row is wrong.codeis stable and safe to branch on. The list is not exhaustive and will grow, so treat anything you do not recognise as a generic validation failure — never as a hard crash.- All the problems in one request come back together. Show the operator the whole list, not just the first.
Stable codes so far
Status codes
Push an order in from another system
You have an order in a CRM, an ERP, or a spreadsheet, and you want it on the floor in Factory. This is the whole path: resolve, build, write, recover.
1 · Resolve the customer
List with the companyName filter — it matches case-insensitively. One hit, take the id. No hits, create the customer first. Company name and email are required and the name must be unique within your company.
2 · Build the lines
A line is one of six kinds and you set exactly one. Which you reach for depends on how well the thing you are selling is modelled in Factory.
Do not send totals. Subtotal, tax and total are derived by the server from the lines, and it will recompute them whatever you send.
3 · Write it once, safely
Generate a UUID and send it as requestId. A network timeout is then harmless: repeat the identical request and you get the original order back rather than a duplicate on the floor.
4 · Recover from a rejection
A 400 lists every field-level problem at once, each with a param pointing at the field. customer_not_found and product_not_found are the two you will hit most, and both mean your resolution step went stale. Re-resolve and retry with the same request id. Full detail on the errors page.
5 · Attach drawings, if you have flashings
Flashing lines can carry drawings, and they are the one line kind that must be present at creation — adding one to an existing order is refused with 501. Include them up front (or on the quote before conversion). The create response returns a drawings list, one entry per flashing line in the order you sent them, pairing your tempId with the assigned drawingId — address UploadDrawingSvg from it directly, no re-read needed. Then upload the rendered SVG for each. Every drawing reports its own outcome, and one that is not part of the order, not image/svg+xml, or already deleted is skipped rather than failing the batch.
Resolve customers and catalogue
Nothing on a sales document is written by name. Before you can build a single line you need ids — and for catalogue lines, the right id out of four that look alike.
Customers
List with companyName to resolve by name. List entries are summaries — id, company name, billing address, last order time. If you need the email, contacts, tax identifier or price level, fetch the full record with GetCustomer. Cache the id against your own customer record; names get edited, ids do not. Alternatively, CreateOrder and CreateQuote accept companyName directly instead of customerId — the server resolves the name for you. Exactly one of the two must be set.
Search first, then drill in
One query across products, kits and flashings, returning minimal ranked hits. Every whitespace-separated word has to match something: a name, a category, a material, or an attribute value such as an item code. Then fetch full detail by the hit's id.
Which id goes on the line
This is the step that trips people up. A product has variant rows; a row has a price at each of your price levels. The line requires the product id; the row and row-price are optional refinements.
Send priceLevelId where rowPriceId is expected and it is rejected — different prefixes, deliberately. Omit both and the server picks the default row at your default price level.
Colour is per line, not per variant
Variant rows are identified by attributes like thickness and size. Colour usually is not one of them — it is chosen per line from the row's colourOptions, which come from the material, so every row sharing a material offers the same list. An empty list means the product has no colour choice at all. The same rule applies to flashings, where the selectable colours sit on the template.
Kits and flashings
Listings are deliberately light: ListProducts and ListKits leave rows and component trees empty. Call GetProduct or GetKit for the detail. Flashing templates are one flashing at one thickness — to change thickness you change template, not a field. And when you build a kit line from a template, echo the component's productName through: the backend stores component names as given and does not derive them.
Keep another system in sync
There are no webhooks in v1. You poll — but there is a feed built for exactly this, so polling stays cheap and correct.
Use the query feed, not the list
Both endpoints return orders. Only one is safe to page through while things are changing underneath you.
updatedSince window. This is the one to build a poller on.The loop
Ask for everything since your checkpoint, page to the end, then move the checkpoint to the last document you actually processed — not to “now”. Overlap the window by a minute or two: updatedSince is inclusive, so re-seeing one document is normal and your handler should be idempotent anyway.
Two feeds, one document
Quotes and orders are separate feeds and a document lives in exactly one at a time. Poll both, with their own checkpoints.
quote_ for order_ and checking the order feed for that id.Backfilling a bounded window
Pair updatedSince with updatedBefore to walk a closed window rather than everything up to now — the way to backfill history in chunks without one enormous run, or to re-pull a single day you suspect you mishandled. The lower bound is inclusive, the upper bound exclusive, so consecutive windows tile without overlapping.
GET /v1/orders:query?updatedSince=2026-07-01T00:00:00Z&updatedBefore=2026-08-01T00:00:00Z
Archiving arrives in the feed
Pass includeArchived=true and the feed carries live and archived orders together. Because archiving stamps the order's update time, you see it happen in-band: the order comes round again with isArchived: true and a fresh timestamp, and a restore comes round with false. Your existing upsert handles both — there is no second feed to reconcile and no deletion to infer.
Leave the flag off and archiving looks like a document that simply stopped updating, which is indistinguishable from one that is merely quiet. If your mirror needs to know, turn it on.
What never shows up
- Work-in-progress documents that have not yet become a quote or an order.
- Quotes on the order feed, or orders on the quote feed. Ever.
- Deletions, as anything other than an absence.
Narrowing the feed
On top of the update window, both feeds accept a creation-time window — createdAfter (inclusive) and createdBefore (exclusive) — which combines with it: recent changes to orders created in a period. The order feed also filters by customer, reference substring, draft or submitted, workflow status id or name, payment status, received status, fulfilment method, and a required-by window (requiredAfter/requiredBefore). Discover your account's status ids from GET /v1/company/order-statuses — they are per-account, so do not hard-code them.
Changelog
Changes inside v1 are additive. Anything breaking ships under a new version path, announced ahead of time.
API reference
{{ opCount }} operations across {{ svcCount }} services, generated from the v1 OpenAPI description. Everything here is the contract — if it is not on this page, do not depend on it.
{{ g.label }}
{{ g.longBlurb }}
{{ opTitle }}
{{ s.v }}
Parameters
Request body
application/json · {{ opBodyName }}
Responses
Returns
{{ opResultName }}