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
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 notationThe 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 ANDPass 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.
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.
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 guardThe 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 mergedPATCH 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.