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 as the backstop.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. Search is in preview, so it is not yet covered by the compatibility guarantees.
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 can never create a second order: it is rejected with a 409 that carries the original order’s id.
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 operations
A few operations 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. Webhooks are administrator-only across the board: every operation on subscriptions and deliveries returns 403 unless the token's user is an administrator.
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 orderNumber. 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
Every create-style write — CreateOrder, CreateQuote, CreateCustomer, the add-line-item calls, and the message and attachment posts — takes a client-generated UUID in requestId. Requests carrying the same key are executed at most once: any repeat is rejected with a 409, and if the original request completed the error body's resourceId carries the id of the resource it created. Keys are scoped to the API key that sends them and retained for at least 24 hours.
resourceId in the error body, or verify with a read (ListCustomers with the companyName filter, comparing the full name, GetOrder, GetQuote), then send a fresh key if you still need a new document — a key is consumed by its first attempt, success or failure. If the idempotency store is unavailable, requests carrying a key are rejected with 503; requests without one are unaffected.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.
Measurements price the line
On the lineal and square pricing strategies the priced amount comes from measurements — an array of measured pieces on the line's pricing object, and on each kit component. One entry is one piece size: length, width and amount.
All three values are decimal strings in the strategy's native unit — metres for the *_METRES strategies, feet for *_FEET. width belongs to the square strategies only — a width on a lineal entry is rejected, and a square entry without one is rejected. amount is how many pieces of that size and defaults to 1. On these strategies the measured amount is the priced quantity — quantity is not a factor. The one legacy exception is an on-the-fly line on an account without server-side derivation, where the old formula still multiplies by it. The server sums the pieces, applying your account's minimum-length setting on lineal strategies, to derive the amount that scales the line total.
asBuiltMeasurements sits beside measurements everywhere it appears — what production actually cut, in the same shape and units. As-builts drive cost, margin and stock consumption, never the line price, and whenever you omit them (create and update alike) they default to measurements. Send them only when the actual figures differ from what was quoted.
productKit.components — and inside each of its subKits — carries its own measurements and asBuiltMeasurements pair, with the same rules as a line. The old contract's names are gone — measurementItems and actualMeasurementItems on lines, initialMeasurementItems and sumLengths on kit components — send the typed pair instead.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. v1 is a stable contract: every published operation, field and value keeps its meaning and wire form for the life of the version. Inside v1 changes are additive and can ship at any time — new endpoints, new optional request fields and query parameters, new response fields, a list operation starting to populate a field it left empty, new enum values, new error types and codes, new webhook event types, limits raised or patterns widened, and new items marked Preview. Build for that: ignore fields you do not recognise, treat an enum value you do not recognise as “other” rather than failing, treat an unrecognised error type as a generic error of its HTTP status, keep page tokens opaque, and never parse an id beyond its prefix.
Breaking changes never ship inside v1: removing or renaming anything, adding a required request field, changing a type, a JSON name, a default or a meaning, changing which status or error type an outcome returns, making an always-present field conditional, reducing what a list operation populates, tightening validation on inputs that succeed today, or changing documented pagination or ordering. They arrive under a new version path or, for a removal, at the end of a deprecation period — announced in the changelog and marked in the description with its replacement and end date, and kept working for at least 12 months. When a new major version ships, v1 stays served for at least 12 months after it is generally available.
An endpoint or field whose description begins with “Preview:” is published for early use and is not yet covered by these guarantees; it can change or be withdrawn with 30 days’ notice in the changelog, and it graduates when the marker is removed. Catalogue search is in preview today. Key order, whitespace, the wording of messages, the contents of page tokens, concrete rate-limit numbers, response timing and any behaviour the documentation does not describe are never part of the contract.
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 an idempotency conflict it holds one entry whose code says which kind, and resourceId carries the id the original request created; 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
API conventions
{{ sec.title }}
{{ s.v }}
Money, enums and the versioning policy are explained in core concepts. Field filtering and errors each have a page of their own, with examples.
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 — a case-insensitive contains-match, so compare each hit's full company name with the one you hold before taking its id; one hit is not proof of an exact match. 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 order totals. Subtotal, tax and total are derived by the server from the lines, and it will recompute them whatever you send.
Line totals are different: each priced line carries its own totalPrice, computed as quantity times unit price times (1 minus discount over 100), rounded half up to the cent. Once server-side derivation is enabled for your account you can omit totalPrice — and on catalogue lines even unitPrice, sending cost and markup instead — and the server prices the line for you. A value you do send is checked, and a mismatch comes back as a 400 naming the line instead of being stored. Note the server keeps markup and margin consistent with the stored prices whenever cost is set — you will not always read back the values you sent.
totalPrice. A standard kit derives its parent total as the sum of its components, so every priced component must carry its own totalPrice. Measurement-priced lines are priced from measurements alone — quantity is not a factor. asBuiltMeasurements is optional and defaults to measurements; send it only when production cut something different from what was quoted.3 · Write it once, safely
Generate a UUID and send it as requestId. A network timeout is then harmless: the API executes a key at most once, so a repeat can never put a duplicate on the floor — it comes back as a 409 whose error body carries the id of the order the first attempt created. Read the id out of the error, or verify with GetOrder.
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 a fresh request id — a key is consumed by its first attempt, even one that failed, so repeating it answers 409. Full detail on the errors page.
5 · Attach drawings, if you have flashings
Flashing lines must carry their drawing, and they are the one line kind that must be present at creation — adding one to an existing order, replacing it or removing it 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. For the geometry itself and the figures you must compute, see the flashing guide.
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 — a case-insensitive contains-match, so compare each hit's full company name before taking its id. 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. CreateOrder and CreateQuote take customerId only: a request that sends companyName instead is refused with 501 (not_implemented).
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: component names are stored as given and are not derived from the product.
Keep another system in sync
Webhooks announce order and quote changes the moment they happen; the query feed is the ledger that makes your copy complete. Build the loop on the feed first, then let deliveries trigger it.
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.
Let webhooks trigger the loop
Subscribe an HTTPS endpoint with POST /v1/webhooks, picking the event types you care about — created, updated, status changes and archival on orders; created, updated, status changes and conversion on quotes. Store the whsec_ secret from the create response: it is shown exactly once, and the full string is the HMAC-SHA256 key for the X-Factory-Signature header on every delivery. Webhook operations are administrator-only — subscribing, listing deliveries, all of it — so run this part of the loop with an administrator's key.
Treat a delivery as a trigger, not a source of truth — the body names the document that changed by id (data.order.id or data.quote.id, plus data.previous on a transition) and never carries the document itself: acknowledge it with a 2xx, then run the same checkpointed loop. Failed deliveries retry for about ten hours across six attempts before they exhaust, and a subscription with nothing delivered successfully for seven days is disabled until you re-enable it — so keep a slow timer on the loop as the backstop. GET /v1/webhook-deliveries lists every attempt with the status your endpoint returned, which makes a missed event a lookup rather than a guess.
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.
Build a flashing line
A flashing is a folded sheet-metal profile, drawn by hand and cut to a list of lengths. The API stores a flashing line exactly as you send it and never prices or checks it, so this guide works one through from the sketch: the numbers you compute, the drawing geometry, the write, and the SVG upload.
1 · Start from the sketch
The example is a Colorbond flashing in Basalt at 0.55 mm: a 10 mm crush fold on the top edge, a 110 mm upright, a 90° bend, a 320 mm flat, a 140° bend and a 10 mm kick. The cut list is three pieces at 1200 mm and two at 925 mm, at $20.00 a metre. Resolve the template first — page ListFlashings and match on flashingName, thickness and colours — and keep its flashtpl_ id. Every flashing template reports the same productType, so that filter does not narrow the list.
Lengths and girths are always millimetres and totalLength is always metres, whatever measurement system the account uses.
2 · Work out the numbers
Four values on the line are yours to compute. The server stores them as sent and never cross-checks them against the drawing or the cut list.
totalPrice is $112.00 — while totalLength stays 5.45. The API does not apply the minimum for you.3 · Draw it
A drawing is a set of points joined by edges. The points carry the sketch's canvas coordinates, which only lay it out; the sizes in lines.values are the real dimensions, so send isFreeDrawing as true. Record every edge at both ends — p1.vectors lists p2 and p2.connect lists p1 — key each size "from-to", and give every edge a size. Angles are keyed by the point they sit on.
A finish sits on the point at the finished end. flip chooses the side: looking from that end along its edge, false folds to the right and true to the left — here true turns the crush fold into the inside of the L. The arrow marks the front face; 0 points left and the angle turns clockwise on screen, so 135 points up and to the right.
Give the drawing a tempId that no other drawing in the request uses, and number it 0 — the server does not assign drawingNumber, and documents sort drawings by it. Its side defaults to the far side; any otherSides default to the near side.
4 · Write the order
Send the line with the document. A flashing line cannot be added to an existing order, replaced or removed, so it goes in the create call — or onto a quote before conversion. priceLevel is a name, not an id. thickness is stored as sent and never compared with the template, so make it match. Amounts are micros of the account currency.
5 · Map the drawing id and upload the SVG
The create response pairs the tempId you sent with the assigned drawingId in its drawings list, one entry per flashing line in the order you sent them.
Render the drawing yourself and upload the image base64-encoded. The decoded SVG must be at most 2,000,000 bytes, and each drawing in the batch reports its own outcome.
6 · What the server checks
- A flashing line without a drawing is rejected with 400 (
validation_failure). - Once the document is submitted,
templateId,priceLevel,colour,thickness,bends,totalGirth,unitPriceand a drawing with points are required, andbendsandtotalGirthmust be greater than zero. colouris checked against the template's colours. Everything else — thickness, bends, girth, length, the cut list and every price — is stored exactly as sent.- A drawing whose points, lines or angles exceed about 15,000 characters of JSON is rejected with 400.
GetOrder to confirm the line, then open it in the Factory app. The drawing renders from the geometry you sent, and the app recomputes girth, bends and price from the same rules described here — if its figures differ from yours, the difference is in your numbers, and the stored values are what invoices will use.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. Machine-readable copies live under OpenAPI documents — one self-contained file per service.
{{ g.label }}
{{ g.longBlurb }}
OpenAPI documents
Machine-readable descriptions of the Factory Sales API v1, served byte-for-byte as they are generated from the contract. No rendered pages behind these links — the file is the document.
The whole API
One document per service
Each file is a complete OpenAPI 3.0 document on its own — the service's paths, every schema they reference, bearer auth, servers, and the full API conventions text. That makes a slice small enough to hand to an LLM whole ({{ specsRange }} tokens) where the whole API ({{ specsWhole }} tokens) is not.
This table is generated from the manifest at /openapi/services/index.json — fetch that to enumerate the documents programmatically.
Every document carries the same operational contract in its info.description, rendered once on the API conventions page. The human-readable version of all of this is the API reference.
{{ opTitle }}
{{ s.v }}
Parameters
Request body
application/json · {{ opBodyName }}
Responses
Returns
{{ opResultName }}