Factory Developers
factory.app Get an API key
Factory Sales API · v1

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.

Start the quickstart Browse the reference

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.

No webhooks
Detect new and changed documents by polling the change feeds with an updatedSince checkpoint.
Tax is advisory
Send a tax descriptor if you like, but the server applies your account's single configured rate and currency, then echoes back what it used.
Some writes are app-only
You can create orders, quotes and customers, edit line items on either document, set labels, and update stock levels. Workflow status, fulfilment and payment still move in the Factory app only, and show up on your next read.
Rate limits unannounced
Requests are limited and a 429 carries Retry-After. Concrete numbers are coming — back off and retry.
Get started

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.

1

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.

Store it as FACTORY_API_KEY in your environment. Every sample on this site reads it from there.
2

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"
{{ tk.v }}
3

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.

{{ tk.v }}
4

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" }
        }
      }
    ]
  }'
Read core concepts before you write anything real — money arrives as micros and every 64-bit number is a string. Then push an order in covers the full path including validation failures.
Get started

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

Status
Meaning
401
Missing, malformed, or expired token. Over gRPC, UNAUTHENTICATED.
403
Authenticated, but the operation needs a company administrator.
404
The id does not exist in your company. Also what you get for a quote id on an order endpoint.

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.
Concepts

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.

{{ p }}
When an accepted quote becomes an order, the suffix is kept and only the prefix changes: 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.

{{ tk.v }}

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.

Where
What the flag does
ListOrders?archived=
Swaps which set you are listing. It is not a superset — true returns only archived orders.
GetOrder?includeArchived=
Without it, an archived order is a 404. With it, you can read the order but still not write to it.
QueryOrders?includeArchived=
Widens the feed to live and archived together. See the sync guide — this one is genuinely useful.

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.

In the rare case the document is created and the labelling then fails, the error names the new document id and the document exists without its labels. Do not retry the create — you would get a second document. Read the id out of the error and attach the labels with 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.

Concepts

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:

{{ tk.v }}
{{ tk.v }}

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:

{{ tk.v }}

Nested fields

Use dot notation to reach inside nested objects:

{{ tk.v }}
{{ tk.v }}

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:

{{ tk.v }}
{{ tk.v }}

Detail endpoints

Filtering works identically on single-resource GETs. You do not need to name the wrapper key:

{{ tk.v }}
{{ tk.v }}

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:

{{ tk.v }}
{{ tk.v }}

Rules

  • Field names are camelCase — the same names you see in responses.
  • Dot paths map across arrays. labels.name keeps name on every element of labels.
  • If both fields and excludeFields are supplied, fields wins 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 2xx JSON responses only. Error bodies are never filtered.
Concepts

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.

{{ tk.v }}

Reading it

  • type is the first thing to branch on — validation_failure, not_found, failed_precondition, conflict, rate_limited. Both validation_failure and failed_precondition are 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.
  • param points 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.
  • code is 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

Code
What went wrong
customer_not_found
The customer reference on a create could not be resolved.
product_not_found
A catalogue line references a product id that does not exist.

Status codes

HTTP
gRPC
When
{{ r.http }}
{{ r.grpc }}
{{ r.when }}
Guide

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.

{{ tk.v }}

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.

Line kind
Use it when
{{ k.name }}
{{ k.use }}

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.

{{ tk.v }}

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.

Line items stay editable — add, replace or remove them, and totals recompute each time. Once the order is invoiced those calls are refused. Workflow status, fulfilment and payment move in the Factory app only, and you see them on your next read. To follow the order from here, use the change feed.
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. 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.

Result order is relevance-based and can change between identical requests. It is not a stable contract — never cache position, and never auto-pick hit one without a human or an exact item-code match behind it.

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.

Id
What it identifies
{{ c.id }}
{{ c.what }}

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.

Guide

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.

GET /v1/orders
Most recently updated first
Good for looking one order up. The ordering shifts as orders change, so a document can move between pages while you are reading.
GET /v1/orders:query
Oldest change first
Stable ordering plus an 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.

{{ tk.v }}

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.

When a quote is accepted in the Factory app it becomes an order. It leaves the quote feed with no tombstone — no final deleted record, it simply stops updating — and appears on the order feed sharing the same 26-character suffix. A quote that goes quiet is reconciled by swapping 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

Changelog

Changes inside v1 are additive. Anything breaking ships under a new version path, announced ahead of time.

{{ c.date }}
{{ c.tag }}
{{ c.title }}
{{ c.body }}
Reference

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 }}

API reference / {{ opService }}

{{ opTitle }}

{{ opMethod }} {{ opPath }}

{{ s.v }}{{ s.v }}

Request
{{ tk.v }}
Response
{{ resSchemaName }}
{{ tk.v }}
Try it {{ sendNote }}

Parameters

{{ p.name }} {{ p.type }} {{ p.reqLabel }} {{ p.in }} {{ c }}
{{ s.v }}{{ s.v }}
{{ e }}

Request body

application/json · {{ opBodyName }}

{{ r.name }} {{ r.type }} {{ r.reqLabel }} {{ c }}
{{ s.v }}{{ s.v }}
{{ e }}

Responses

{{ r.code }}
{{ s.v }}{{ s.v }}
{{ r.schema }}

Returns

{{ opResultName }}

{{ r.name }} {{ r.type }} {{ c }}
{{ s.v }}{{ s.v }}