Skip to main content

Status updates: polling and webhooks

Two ways to get statuses back, and most integrations use both:

PollingWebhooks
HowYou ask for orders changed since your last pollWe POST an event to your URL as it happens
CoversEvery change: planned, picked up, delivered, failed, canceledCreated, changed through the API, delivered, order action added — not planned, picked up or failed
AvailabilityLiveLive, switched on per project on request
If your side is downNothing is lost; the next poll catches upMissed 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 default whereOp). modified_at is null on an order that has never changed since it was created, so modified_at > T alone misses new orders.
  • The created_at condition also lifts the 365-day limit. Without any created_at selector, only orders created in the last year are returned, so a change to an older order would be missed.
  • modified_at changes on every change to the order — by you, a dispatcher, route planning or the driver app.
  • limit defaults to 10. Send limit (max 1000) and page with offset until a page comes back with fewer rows than limit.
  • select keeps the response small. Leave it out to get the whole order.

A safe polling loop​

  1. Remember T, the largest modified_at or created_at you have seen — use the timestamps from our responses, not your own clock.
  2. Every few minutes, query with T minus a small overlap (a minute is plenty).
  3. Process each order only if its modified_at (or created_at) is newer than what you already stored for that id. The overlap means you will see some orders twice; this makes that harmless.
  4. 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 seeMeans
status: ROUTEDPlanned onto a route (route_id is set)
status: ONGOING, task_status: PICKUP_COMPLETEDPicked up at the shop
task_status: DELIVERY_COMPLETEDDelivered — read status_reason for how
status: FAILEDNot delivered this time — read status_reason; it may be planned again
status: CANCELEDCalled off

See Statuses and reasons.

GET /v1/orders/status is for tracking, not for reasons

Track 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​

EventSent whendata is
order.createdAn order is created through POST /v1/ordersThe order
order.modifiedAn order is changed through PATCH /v1/orders/:id — by your system or by a dispatcherThe order
order.deliveredAn order is delivered, once: the first time it becomes COMPLETED or its delivery is completed, however that happened, including by a driverThe order
order_action.createdAn order action is added, for example the photo or signature a driver takes when completing a stopThe order action
Not every change is an event

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.modified and order.delivered for the same change may arrive in either order. Compare data.modified_at with 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.delivered for 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".