Status updates: polling and webhooks
Two ways to get statuses back, and most integrations use both:
| Polling | Webhooks | |
|---|---|---|
| How | You ask for orders changed since your last poll | We POST an event to your URL as it happens |
| Covers | Every change: planned, picked up, delivered, failed, canceled | Created, changed through the API, delivered, order action added — not planned, picked up or failed |
| Availability | Live | Live, switched on per project on request |
| If your side is down | Nothing is lost; the next poll catches up | Missed events are not resent yet; poll to catch up |
Use webhooks for speed on deliveries and proof of delivery, and a poll every few minutes as the complete record.
Polling
Ask for orders created or modified since your last poll:
curl -s -G 'https://orders-api.cloud.zoopit.no/v1/orders' \
-H 'Authorization: Bearer <token>' \
--data-urlencode 'where=[{"modified_at":{">":"2026-12-04T09:58:00Z"}},{"created_at":{">":"2026-12-04T09:58:00Z"}}]' \
--data-urlencode 'order=[["created_at","ASC"]]' \
--data-urlencode 'select=["id","reference_id","status","task_status","status_reason","status_description","actual_amounts","route_id","created_at","modified_at"]' \
--data-urlencode 'limit=1000' \
--data-urlencode 'offset=0'
Why it looks like this:
- Two conditions, joined by
OR(the defaultwhereOp).modified_atisnullon an order that has never changed since it was created, somodified_at > Talone misses new orders. - The
created_atcondition also lifts the 365-day limit. Without anycreated_atselector, only orders created in the last year are returned, so a change to an older order would be missed. modified_atchanges on every change to the order — by you, a dispatcher, route planning or the driver app.limitdefaults to 10. Sendlimit(max 1000) and page withoffsetuntil a page comes back with fewer rows thanlimit.selectkeeps the response small. Leave it out to get the whole order.
A safe polling loop
- Remember
T, the largestmodified_atorcreated_atyou have seen — use the timestamps from our responses, not your own clock. - Every few minutes, query with
Tminus a small overlap (a minute is plenty). - Process each order only if its
modified_at(orcreated_at) is newer than what you already stored for thatid. The overlap means you will see some orders twice; this makes that harmless. - Store the new
T.
For large result sets you can stream instead of paging: send Accept: application/x-ndjson and
each order arrives as one JSON line.
What to look for
| You see | Means |
|---|---|
status: ROUTED | Planned onto a route (route_id is set) |
status: ONGOING, task_status: PICKUP_COMPLETED | Picked up at the shop |
task_status: DELIVERY_COMPLETED | Delivered — read status_reason for how |
status: FAILED | Not delivered this time — read status_reason; it may be planned again |
status: CANCELED | Called off |
See Statuses and reasons.
GET /v1/orders/status is for tracking, not for reasonsTrack orders returns a tracking summary with activities,
ETAs and the status log, but not status_reason, status_description or actual_amounts, and
it cannot be sorted. Use GET /v1/orders for the polling above.
Webhooks
Switch them on
Webhook sending is switched on per project, on request. Ask us when you ask for your API key. You can create subscriptions before that; nothing is sent until the project is switched on.
Subscribe
POST https://orders-api.cloud.zoopit.no/v1/webhook_subscriptions
Authorization: Bearer <token>
Content-Type: application/json
{
"owner": "<your project id>",
"name": "ERP status feed",
"url": "https://erp.example.com/zoopit/webhooks",
"events": ["order.delivered", "order_action.created", "order.modified"]
}
The 201 response contains a secret. Store it now: it is returned only once, and you need
it to verify every request. To get a new secret, create a new subscription and delete the old
one.
The URL must:
- use
https:// - resolve to a public address (no private, loopback or link-local addresses)
- not contain a username or password
A URL that breaks these rules is refused with 400. Redirects are not followed.
Manage subscriptions with GET /v1/webhook_subscriptions,
PATCH /v1/webhook_subscriptions/:id (send modified_at from your latest read; set
"enabled": false to pause) and DELETE /v1/webhook_subscriptions/:id. Every field is in
the Webhooks reference.
Events
| Event | Sent when | data is |
|---|---|---|
order.created | An order is created through POST /v1/orders | The order |
order.modified | An order is changed through PATCH /v1/orders/:id — by your system or by a dispatcher | The order |
order.delivered | An order is delivered, once: the first time it becomes COMPLETED or its delivery is completed, however that happened, including by a driver | The order |
order_action.created | An order action is added, for example the photo or signature a driver takes when completing a stop | The order action |
Route planning (ROUTED), a pickup (ONGOING) and a failed delivery (FAILED) do not send
a webhook today. Poll for those. Your subscription only receives orders from the project it was
created for.
The request
POST /zoopit/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Zoopit-Webhooks/1
X-Zoopit-Event: order.delivered
X-Zoopit-Subscription-Id: 7d0f5e0e-3a7c-4c1e-9a55-2f0c8d1b6e42
X-Zoopit-Signature: sha256=5c1f0e…
{
"event": "order.delivered",
"order_id": "559ade03-cb22-41b9-8987-567c192d9d08",
"sent_at": "2026-12-04T11:42:07.512Z",
"data": {
"id": "559ade03-cb22-41b9-8987-567c192d9d08",
"reference_id": "100234",
"status": "COMPLETED",
"task_status": "DELIVERY_COMPLETED",
"status_reason": "completed.alternative_recipient",
"status_description": "Neighbour in flat 2B",
"actual_amounts": null,
"route_id": "c3a3d0b4-6f0e-4d7c-8a51-0e4b7a6c9f10",
"modified_at": "2026-12-04 11:42:07.401+00",
"…": "the rest of the order"
}
}
data is the order as stored: tasks and locations are ids, not expanded. Fetch
GET /v1/orders/:id when you need the addresses, the route or the order actions.
Verify the signature
X-Zoopit-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body,
keyed with your subscription's secret. Use the secret string exactly as you received it. Compute
the HMAC over the bytes you received, before any JSON parsing, and compare in constant time.
Node.js (Express)
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const secret = process.env.ZOOPIT_WEBHOOK_SECRET
// express.raw keeps the body as a Buffer. express.json() would re-serialise it and break the HMAC.
app.post('/zoopit/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(req.body).digest('hex')
const received = req.get('x-zoopit-signature') ?? ''
const valid = received.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
if (!valid) return res.sendStatus(401)
const event = JSON.parse(req.body.toString('utf8'))
saveForProcessing(event) // store it, then answer; do the work afterwards
res.sendStatus(204)
})
PHP
<?php
$secret = getenv('ZOOPIT_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // the raw body, not $_POST
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
$received = $_SERVER['HTTP_X_ZOOPIT_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
save_for_processing($event); // store it, then answer; do the work afterwards
http_response_code(204);
Delivery rules
- One attempt, no retries yet. Each event is POSTed once. Retries are coming; until then, poll to catch anything you missed.
- Answer within 5 seconds with any
2xx. Anything else, a timeout or a redirect counts as failed. Store the event and answer first; process it afterwards. - Events can arrive out of order.
order.modifiedandorder.deliveredfor the same change may arrive in either order. Comparedata.modified_atwith what you have stored and keep the newest. - Expect the same order more than once. A failed delivery may be followed by a later
order.deliveredfor the same order once it is re-delivered.
Delivery log
Every attempt is logged, newest first:
curl -s -G 'https://orders-api.cloud.zoopit.no/v1/webhook_deliveries' \
-H 'Authorization: Bearer <token>' \
--data-urlencode 'where=[{"subscription_id":{"=":"<subscription id>"}}]' \
--data-urlencode 'limit=50'
Each entry has event, order_id, the payload that was sent, status_code (your answer, or
0 if there was none), response_excerpt (the start of your answer, or why the request
failed), delivered_at and duration_ms. Check it first when an event "never arrived".