Skip to main content

Order lifecycle

How an order moves from created to finished, and how to follow it from your own system. Read this before building an integration — several of the rules below are not guessable from the field reference.

The states​

See status for the full table. The short version:

CREATED ──▶ RESERVED ──▶ ROUTED ──▶ ONGOING ──▶ COMPLETED
│
├──▶ FAILED ──▶ (re-planned)
│
any state ───────────────────────────┴──▶ CANCELED

Only COMPLETED and CANCELED are final. FAILED means the last attempt did not succeed; the work returns to the unplanned queue and may be re-planned, so the same order can reach COMPLETED on a later day. An integration that closes its own record on FAILED will be wrong, and one that rejects a second transition on the same order as a duplicate will lose the real outcome.

status describes the order. task_status describes what physically happened last — an order with both a pickup and a delivery reports PICKUP_COMPLETED while status is still ONGOING.

Creating an order​

Where the request shape lives

The authoritative, always-current schema for every endpoint on this page is the generated Orders API reference, built from the running service. The examples below are illustrative — when they and the generated reference disagree, the generated reference is right.

This page exists for what a schema cannot express: what the values mean, which transitions are final, and which defaults will surprise you.

POST /v1/orders
Authorization: Bearer <token>
Content-Type: application/json

owner is the only required field — it is the project id the order belongs to. Everything else is optional, but an order without tasks cannot be planned: it is created without error and listed, and planning never turns it into a stop.

{
"owner": "b7e4f1a2-3c5d-4e6f-8a9b-0c1d2e3f4a5b",
"reference_id": "ERP-2026-00184",
"amounts": { "colli": 2, "kg": 7 },
"tasks": [
{
"type": "pickup",
"amounts": { "colli": 2, "kg": 7 },
"places": [{
"duration": 5,
"time_windows": [{ "start": "2026-12-04T07:00:00Z", "end": "2026-12-04T09:00:00Z" }],
"location": {
"name": "Warehouse",
"address": {
"country_code": "no",
"street_address": "Bogstadveien 1",
"zip_code": "0355",
"city": "Oslo"
}
}
}]
},
{
"type": "delivery",
"amounts": { "colli": 2, "kg": 7 },
"places": [{
"duration": 5,
"time_windows": [{ "start": "2026-12-04T09:00:00Z", "end": "2026-12-04T15:00:00Z" }],
"location": {
"name": "Kari Nordmann",
"phone_number": "+4790000000",
"address": {
"country_code": "no",
"street_address": "Kirkeveien 1",
"zip_code": "0268",
"city": "Oslo"
}
}
}]
}
]
}

duration is the time spent at the stop, in minutes. Give the pickup and the delivery the same amounts.

The order is created as RESERVED unless you send "status": "CREATED".

Returns 201 with { "resource": …, "related": … } — see API response. Keep resource.id; it is the key for everything below. For a complete walkthrough, see Creating orders.

Addresses​

A location takes either a structured address (above) or a free-text search:

"address": { "country_code": "no", "search": "Kirkeveien 1, 0268 Oslo" }

Structured is preferred for an ERP integration — search is geocoded, and a geocoder that picks the wrong match produces a delivery to the wrong place with no error. Include coordinates if you already hold them and the address is unambiguous.

delivery and pickup​

An order also has delivery.oneOf and pickup.oneOf, which take the same places as a task. Planning does not read them, but recipient SMS templates do: to text the recipient, put the recipient in delivery.oneOf as well as in the delivery task. See Notifications.

reference_id​

Your own key. Not unique or validated by us — it is carried through so you can reconcile. Use it.

Reading one order back​

GET /v1/orders/:order_id

Returns the full order plus a related block with the locations, activities, route and order actions it references, so you do not need a second round trip.

Following status from your own system​

Poll GET /v1/orders for orders created or modified since your last poll:

GET /v1/orders?where=[{"modified_at":{">":"<T>"}},{"created_at":{">":"<T>"}}]&limit=1000

modified_at changes on every change to the order, including route planning and the driver's progress. It is null on an order never changed since it was created, which is why created_at is the second condition. Webhooks can push the most important changes as they happen. Both are described, with a safe polling loop, in Status updates.

GET /v1/orders/status is a tracking summary: id, reference_id, route_id, status, task_status, the tasks and their locations, and — once the order is on a route — its activities, ETAs and status log. It does not carry status_reason, status_description or actual_amounts.

Four query rules that will bite you​

where is a JSON string, not bracket notation

The value of where must be a URL-encoded JSON array: where=%5B%7B%22status%22%3A%7B%22%3D%22%3A%22ONGOING%22%7D%7D%5D.

PHP/Rails-style where[0][status][=]=ONGOING is silently ignored — the parser reads the whole bracketed string as one unrecognised key, so where arrives empty and you get an unfiltered result set that looks like a successful query. No error, no warning. If a filter appears to have no effect, this is why.

Each element is { "<field>": { "<operator>": <value> } }. Operators include =, !=, >, <, IN, IS. Fields inside one element must all match.

whereOp defaults to OR, not AND

Pass two selectors without whereOp and you get every order matching either one. An ERP filtering on "this customer AND today" without it receives that customer's whole history plus every other customer's orders for today. Always send whereOp=AND when you pass more than one selector.

Results are silently limited to the last 365 days

On GET /v1/orders, /v1/orders/count and /v1/orders/grouped_count: if your where contains no created_at selector, the API adds created_at > now() - 1 year for you. No error, no flag in the response — older orders simply are not there. Pass an explicit created_at selector when you need a longer window.

Tenancy is always enforced

Your owners scope is AND-ed onto every query regardless of whereOp. You cannot widen a query into another tenant's data, and you do not need to filter for it.

Updating an order​

PATCH /v1/orders/:order_id

Two rules, both of which cause silent data loss when missed:

modified_at is required, and it is a concurrency guard

The body must carry the modified_at you last read (null if the order has never been modified). If the order changed in between — a driver completed a stop, a dispatcher moved it — your write is rejected with 404 Not found or has been changed rather than overwriting theirs. Always read immediately before you patch; never cache modified_at across a job run.

attributes and tags are replaced, not merged

PATCH sets these fields to exactly what you send. The same goes for amounts, delivery and pickup; fields you leave out of the body are left alone. Sending {"attributes": {"erp_batch": "42"}} deletes every other key that was there, including ones written by other systems. Read the order, merge your keys into the existing object, then send the whole object back.

Setting status through PATCH is how an integration cancels an order ("status": "CANCELED"). Do not use it to mark a delivery complete — that is written by the driver app as the route is executed, and a manual COMPLETED will be overwritten or will contradict the activity record.

Discovering what your own tenant uses​

GET /v1/orders/enums

Returns the distinct values actually present in your data, owner-scoped — not the platform-wide list: status, tags, attribute_keys and the three vehicle_skills sets. Useful for building a mapping table without hardcoding values your tenant never produces, and for discovering which attributes keys are already in use before you write your own.

The same endpoint exists for tasks, order actions and consignments.

Why it ended that way​

status is only half the outcome. A closed vocabulary of reason codes says why — completed.left_unattended, failed.recipient_absent, canceled.by_customer — and for most integrations that is the field worth acting on.

The reason is on the order as status_reason, with status_description and actual_amounts. See outcome reasons.

What is not here yet​

A dedicated change feed — "give me every status transition since cursor X" — is in development. Until it ships, poll GET /v1/orders on modified_at as described above, and use webhooks for the events they cover. When the feed ships it will be documented here with a stated contract version; this page will not change shape underneath you.