For developers
The NORUM API
Connect an ERP, a MES or a co-packer’s own system. The API is small on purpose: it carries the facts a recall and an audit need, and nothing that belongs in the system you already run.
Getting a key
In the workspace, open Integrations and create a key. It is shown once. NORUM stores only a hash of it, so a lost key is replaced rather than recovered — which is the same reason your own secrets should not be recoverable either.
curl https://norum.io/api/v1/products \
-H "Authorization: Bearer norum_live_…"Bearer only. NORUM does not accept a key in the query string: a key in a URL is a key in an access log, a browser history and a referrer header, and offering the convenience once means it is used forever.
Scopes
| Scope | What it allows |
|---|---|
products.read | List products and which version of each is in force. |
traceability.write | Record which supplier lot went into which production batch. |
formulas.read | Read the recipe in force: its lines, its batch size, and the process steps an operator follows. Returned as one version rather than assembled from several, so an ERP cannot scale yesterday’s quantities against today’s instructions. |
specs.read | Read specification revisions, when each took effect, and the hash of its content — so a customer holding a copy can tell whether it is still the one in force without downloading anything. |
documents.read | Read the register of issued documents: number, revision, status and checksum. The list, not the contents. |
traceability.read | Read batches and the lots recorded against them. Separate from writing on purpose: a dashboard should not be able to add to production history, and a machine posting lots at the end of a shift has no reason to read the rest of it back. |
Give a co-packer a key with traceability.write and nothing else. A key that can also read your formulas is a key that reads your formulas.
Endpoints
GET/api/v1/productsproducts.read
Every product in the workspace, with the version in force. The question every integration starts with.
curl https://norum.io/api/v1/products \
-H "Authorization: Bearer norum_live_…"GET/api/v1/formulasformulas.read
The recipe and the procedure, from the same version. Defaults to the version in force, because an integration that pulled a draft would schedule a run against a recipe nobody approved. There is no formulas.write and there will not be one: a formula is authored and approved here, and one posted in from outside would carry no approval.
# the version in force, with its lines and its process steps
curl "https://norum.io/api/v1/formulas" \
-H "Authorization: Bearer norum_live_…"
# a superseded version, for an audit trail
curl "https://norum.io/api/v1/formulas?status=SUPERSEDED" \
-H "Authorization: Bearer norum_live_…"GET/api/v1/specificationsspecs.read
Which revision is current, when it took effect, and what its content hashes to. Compare the hash against the copy on your shelf: equal hashes are the same document, and a revision code is a label somebody typed.
curl "https://norum.io/api/v1/specifications?productId=…" \
-H "Authorization: Bearer norum_live_…"GET/api/v1/documentsdocuments.read
The register of what has been issued. Released documents by default — a draft certificate is one whose results are not all in, and handing it to an external system as though it were a certificate is the failure that rule exists to prevent. The PDF itself goes through a share link, which is revocable and logged.
curl "https://norum.io/api/v1/documents?type=COA" \
-H "Authorization: Bearer norum_live_…"POST/api/v1/batch-lotstraceability.write
Record which supplier lots went into which batches. This is the fact a recall needs and the one NORUM cannot produce on its own, because it does not do receiving.
curl -X POST https://norum.io/api/v1/batch-lots \
-H "Authorization: Bearer norum_live_…" \
-H "Idempotency-Key: 8f3c1b2e-…" \
-H "Content-Type: application/json" \
-d '{
"lots": [
{
"batchCode": "L-2609-04",
"ingredientCode": "WPI-80",
"supplierLotCode": "A4471-2",
"supplierName": "Northern Dairy",
"quantity": "155.0",
"quantityUnit": "kg",
"receivedOn": "2026-08-14"
}
]
}'GET/api/v1/batch-lotstraceability.read
Read the lots back. This is what makes an integration reconcilable — post a shift, read it back, compare — and what lets a co-packer see what they recorded rather than watching it disappear into another company’s workspace.
# everything recently received
curl "https://norum.io/api/v1/batch-lots?limit=50" \
-H "Authorization: Bearer norum_live_…"
# one batch
curl "https://norum.io/api/v1/batch-lots?batchCode=L-2609-04" \
-H "Authorization: Bearer norum_live_…"Retrying a write safely
Send an Idempotency-Key header on every write — any unique string, one per logical request. A retry with the same key returns the original response instead of writing again, with Idempotency-Replayed: true on it.
This matters more here than on most APIs. A timeout is indistinguishable from a failure, so your job runner will retry — and a duplicated ingredient lot does not look like a bug. It looks like a plausible second issue from the warehouse, and it would be found during a recall by somebody who needed the records to be right.
Reusing one key with a different body returns 409 rather than replaying the first answer. That is a client bug, and hiding it would silently drop the second write.
When an upload is refused
A traceability upload is all-or-nothing. If any row names a batch or an ingredient this workspace does not know, nothing is written and the response names every bad row by index.
That is deliberate. A partly applied upload is the worst outcome available: you get an error and cannot tell which half landed, and the batch now carries records that look complete and are not. A recall run against those records would report a clean result it had not earned.
HTTP/1.1 422 Unprocessable Content
{
"apiVersion": "v1",
"error": "VALIDATION_ERROR",
"message": "Nothing was written. Every lot must name a batch and an ingredient this workspace knows.",
"rejected": [
{
"index": 2,
"batchCode": "L-2609-99",
"reason": "No batch with code \"L-2609-99\" in this workspace."
}
]
}Webhooks
NORUM posts a signed JSON body to your endpoint when a version changes. Two headers come with it: Norum-Timestamp, and Norum-Signature, which is the hex HMAC-SHA256 of the timestamp and the body joined by a full stop, keyed with your endpoint’s signing secret.
The timestamp is inside the signed string rather than beside it, so an intercepted delivery cannot be replayed later with a fresh one. It is the scheme Stripe uses, and the one NORUM verifies Stripe with — one idea to get right instead of two.
What NORUM will and will not claim is on the Trust page, and what it keeps in your browser is on the Cookies page.