For developers · Private beta
API and webhooks
The Teams API lets you send orders from your shop or order system straight into the dispatcher website (Inbox in Routes), read their status and get a webhook when a driver delivers — or connect any service with Zapier.
- Create an API key in Team → Integrations.
- Your system sends each order with
POST /orders. It appears in Inbox on its delivery date. - A dispatcher adds the orders to drivers' routes, as with any other stops.
- When a driver marks a stop, the order gets its status, time and proof of delivery, and your webhooks are called.
The API is in private beta. If Integrations says the API is not enabled for your team yet, write to support@courierrouteplanner.com to join the beta. No developer? Use the private Zapier integration (Zapier).
Authentication
Create a key on the dispatcher website: Team → Integrations → Create API key, and give it a name of up to 40 characters (for example, the shop it is for). The team owner and dispatchers can create and revoke keys.
- The key is shown once, right after you create it. Copy it into your server's secrets. We keep only a hash of it, so we can't show it again: if you lose it, revoke it and create a new one.
- Keys start with
rpt_live_. Keep them secret: anyone with a key can add orders to your team and read orders, recipient details and proof of delivery. - Call the API from your server only — never from a browser or a mobile app. Browser (cross-origin) requests are refused.
- Revoke a key at any time in Integrations; requests with it stop working straight away.
Send the key in the Authorization header of every request:
Authorization: Bearer rpt_live_…
curl https://courierrouteplanner.com/api/v1/me \
-H "Authorization: Bearer $RPT_API_KEY"
Requests and errors
https://courierrouteplanner.com/api/v1
Request bodies and responses are JSON (send Content-Type: application/json). Times are ISO 8601 in UTC, for example 2026-10-23T10:42:28.000Z; delivery dates are YYYY-MM-DD. Field names are snake_case.
| Method | Path | What it does |
|---|---|---|
| GET | /me | Team, key, plan and usage |
| POST | /orders | Create an order |
| GET | /orders | List orders |
| GET | /orders/{id} | Get an order |
| DELETE | /orders/{id} | Cancel an order |
| POST | /hooks | Subscribe a webhook |
| GET | /hooks | List webhooks |
| DELETE | /hooks/{id} | Delete a webhook |
Errors come with an HTTP status and a JSON body with a short code and a message for people:
{
"error": "address-not-found",
"message": "The address could not be found. Check it, add the city and postcode, or send lat and lng."
}
| Error | When |
|---|---|
invalidHTTP 400 | The body or a parameter is not valid. The message names the field. |
address-not-foundHTTP 400 | The address could not be found. Add the city and postcode, or send lat and lng. |
unauthorizedHTTP 401 | No key, a malformed key, or a revoked key. |
team-lapsedHTTP 403 | The team's subscription is not active (the trial ended or a payment failed). Pay in Subscription to continue. |
api-disabledHTTP 403 | The API is not enabled. |
team-not-foundHTTP 403 | The team of this key no longer exists. |
not-foundHTTP 404 | No such order or webhook, or an unknown path. |
order-plannedHTTP 409 | The order is already on a route, delivered or failed, so it can't be cancelled through the API. |
too-many-hooksHTTP 409 | The team already has 20 webhooks. Delete one first. |
rate-limitedHTTP 429 | Too many requests. When the daily limit is reached, the body also has "reason": "daily-limit" and reset_at. |
quota-exceededHTTP 429 | The team's monthly allowance of address lookups is used up ("reason": "monthly-limit"). It resets on the 1st of the month (UTC). |
internalHTTP 500 | Something went wrong on our side. Try again later. |
Retry 429 and 500 after a pause. Don't repeat other errors without changing the request. To retry POST /orders safely, send external_id: the same order is never created twice.
Limits
- 600 requests per 10 minutes per key, and 5,000 requests per day per key (UTC day). Over the limit you get
429 rate-limited. - Address lookups. Each order sent with an
address(and withoutlat/lng) uses one address lookup from the team's monthly allowance — the same allowance as addresses imported from files and lists on the website. Orders sent withlatandlng, repeats with the sameexternal_idand addresses that can't be found don't use it.GET /meshows how much is used. - Stops. An order becomes a stop of the monthly stops allowance only when a dispatcher adds it to a route, as with any other stop.
- Up to 10 active API keys and 20 webhooks per team.
Account
GET /me
Checks the key and shows the team, the key, the plan, this month's usage (UTC month) and the API settings. Handy as the first call and for a health check.
curl https://courierrouteplanner.com/api/v1/me \
-H "Authorization: Bearer $RPT_API_KEY"
{
"team": {
"id": "Tk7Qp2Wm9xLs",
"name": "Northside Couriers"
},
"key": {
"id": "k_Qm3xT8vLp2aZ",
"prefix": "rpt_live_x7Gk2PqA",
"name": "Online shop"
},
"plan": {
"status": "active",
"seats": 5,
"paid": true,
"lapsed": false,
"graceUntil": null,
"lapsedReason": null,
"retainUntil": null
},
"limits": {
"month": "2026-10",
"resets_at": "2026-11-01T00:00:00.000Z",
"stops": {
"used": 1240,
"cap": 15000
},
"optimizations": {
"used": 96,
"cap": 5000
},
"address_lookups": {
"used": 310,
"cap": 30000
},
"requests_per_10min": 600,
"requests_per_day": 5000
},
"settings": {
"timezone": "Europe/London",
"share_location": false
}
}
limits— stops added to routes, route optimisations and address lookups this month, each with itscap, and the request limits of the key.settings.timezone— the team's time zone for the defaultdateof new orders. It is taken from the browser of the person who creates the team's first API key.settings.share_location— whether orders include the position where the driver marked the stop (Share the driver’s position at the stop in Integrations).
Orders
An order is a delivery (or pickup) you send from your system. It arrives in Inbox in Routes on its delivery date, and a dispatcher adds it to a driver's route from there.
| Status | Meaning |
|---|---|
inbox | Waiting in Inbox. If the dispatcher removes the stop from its route, or deletes the route, the order comes back here. |
planned | On a route: route_id, stop_id and planned_at are set. |
delivered | The driver marked the stop as delivered (delivered_at, pod). |
failed | The driver marked the stop as failed (failed_at, fail_reason). If the driver undoes a mark, the order is planned again. |
cancelled | Cancelled with DELETE /orders/{id} or dismissed in Inbox. |
POST /orders
Creates an order in Inbox. Send an address, or lat and lng.
curl https://courierrouteplanner.com/api/v1/orders \
-H "Authorization: Bearer $RPT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "SHOP-10482",
"address": "221B Baker Street, London NW1 6XE",
"country": "GB",
"name": "Sam Taylor",
"phone": "+44 20 7946 0958",
"note": "Leave with the concierge",
"date": "2026-10-23",
"time_window": {
"from": "09:00",
"to": "12:00"
},
"service_minutes": 10,
"parcel_code": "1Z999AA10123456784",
"signature_required": true
}'
| Field | Description |
|---|---|
addressstring | Delivery address. Required unless you send lat and lng. Include the city and postcode for the best match. Up to 300 characters. |
latlngnumber | Coordinates of the stop. With both, the address isn't looked up and doesn't use the allowance; address is then only the label the dispatcher and driver see. |
namestring | Recipient's name, up to 80 characters. |
phonestring | Recipient's phone, up to 40 characters. |
notestring | Note for the driver, up to 250 characters. |
external_idstring | Your order number, up to 128 characters. Makes the request idempotent: the same external_id again returns the existing order with status 200 and "created": false, and changes nothing. |
typestring | delivery (default) or pickup. |
time_windowobject | Delivery window { "from": "HH:MM", "to": "HH:MM" } in local time; from must be before to. |
service_minutesnumber | Time the driver spends at this stop, in minutes: 0 to 120, for example 10 or 7.5. It is used for the arrival times and when the route is optimised. Without it the stop gets the route's Minutes per stop. |
datestring | Delivery date YYYY-MM-DD — the day in Inbox where the order appears. Default: today in the team's time zone (settings.timezone in GET /me). |
parcel_codestring | Parcel barcode or label number, up to 128 characters. |
signature_requiredboolean | Whether the recipient's signature is required at this stop. Default: false. |
countrystring | Two-letter country code (ISO 3166-1), for example GB, to help find the address. |
camelCase names (externalId, timeWindow, serviceMinutes, parcelCode, signatureRequired) are accepted too.
{
"order": {
"id": "x_3f9c0e1d2b7a4c58e6f1a0b9c8d7e6f5a4b3c2d1",
"external_id": "SHOP-10482",
"status": "inbox",
"type": "delivery",
"address": "221B Baker Street, London NW1 6XE",
"formatted_address": "221B Baker Street, London NW1 6XE",
"name": "Sam Taylor",
"phone": "+44 20 7946 0958",
"note": "Leave with the concierge",
"lat": 51.523767,
"lng": -0.158555,
"date": "2026-10-23",
"time_window": {
"from": "09:00",
"to": "12:00"
},
"service_minutes": 10,
"parcel_code": "1Z999AA10123456784",
"signature_required": true,
"review": false,
"review_reasons": [],
"source": "api",
"created_at": "2026-10-22T16:05:12.481Z",
"updated_at": "2026-10-22T16:05:12.481Z",
"route_id": null,
"stop_id": null,
"planned_at": null,
"delivered_at": null,
"failed_at": null,
"fail_reason": null,
"cancelled_at": null,
"location": null,
"pod": null
},
"created": true
}
If the address was found with low confidence, review is true and review_reasons says why — check the pin on the website before planning. An address that can't be found returns 400 address-not-found and no order is created.
GET /orders
Lists orders. Without parameters — today's orders (in the team's time zone).
| Parameter | Description |
|---|---|
date | Delivery date YYYY-MM-DD. |
status | inbox, planned, delivered, failed, cancelled. With date — orders of that day with this status; without date — orders with this status on any day. |
curl "https://courierrouteplanner.com/api/v1/orders?date=2026-10-23&status=delivered" \
-H "Authorization: Bearer $RPT_API_KEY"
{
"orders": [
{ …order… }
],
"truncated": false
}
Orders are sorted newest first. A response holds up to 500 orders; "truncated": true means there are more — narrow the query by date and status.
GET /orders/{id}
Returns one order by its id (the one returned when you created it).
curl https://courierrouteplanner.com/api/v1/orders/x_3f9c0e1d2b7a4c58e6f1a0b9c8d7e6f5a4b3c2d1 \
-H "Authorization: Bearer $RPT_API_KEY"
{
"order": {
"id": "x_3f9c0e1d2b7a4c58e6f1a0b9c8d7e6f5a4b3c2d1",
"external_id": "SHOP-10482",
"status": "delivered",
"type": "delivery",
"address": "221B Baker Street, London NW1 6XE",
"formatted_address": "221B Baker Street, London NW1 6XE",
"name": "Sam Taylor",
"phone": "+44 20 7946 0958",
"note": "Leave with the concierge",
"lat": 51.523767,
"lng": -0.158555,
"date": "2026-10-23",
"time_window": {
"from": "09:00",
"to": "12:00"
},
"service_minutes": 10,
"parcel_code": "1Z999AA10123456784",
"signature_required": true,
"review": false,
"review_reasons": [],
"source": "api",
"created_at": "2026-10-22T16:05:12.481Z",
"updated_at": "2026-10-23T10:42:36.017Z",
"route_id": "Vb3kQ9xLm2PzR7tW1cYe",
"stop_id": "st_1761213600412",
"planned_at": "2026-10-22T18:03:10.215Z",
"delivered_at": "2026-10-23T10:42:28.000Z",
"failed_at": null,
"fail_reason": null,
"cancelled_at": null,
"location": {
"lat": 51.523812,
"lng": -0.158491,
"accuracy_m": 9
},
"pod": {
"photos": [
"https://courierrouteplanner.com/api/v1/media/eyJ0IjoiVGs3UXA…"
],
"signature": "https://courierrouteplanner.com/api/v1/media/eyJ0IjoiVGs3UXB…",
"note": "Left with the concierge"
}
}
}
DELETE /orders/{id}
Cancels an order that is still in Inbox: its status becomes cancelled and it leaves Inbox. Cancelling an order that is already cancelled returns it unchanged. Once the order is on a route (planned, delivered or failed), you get 409 order-planned: ask the dispatcher to remove it from the route on the website.
curl -X DELETE https://courierrouteplanner.com/api/v1/orders/x_3f9c0e1d2b7a4c58e6f1a0b9c8d7e6f5a4b3c2d1 \
-H "Authorization: Bearer $RPT_API_KEY"
{
"order": {
"id": "x_3f9c0e1d2b7a4c58e6f1a0b9c8d7e6f5a4b3c2d1",
"external_id": "SHOP-10482",
"status": "cancelled",
…,
"cancelled_at": "2026-10-22T17:30:02.914Z"
}
}
The order object
| Field | Description |
|---|---|
id | Order id. An order sent with external_id always has the same id. |
external_id | Your order number, or null. |
status | inbox, planned, delivered, failed, cancelled (see Orders). |
type | delivery or pickup. |
address | The address as you sent it. |
formatted_address | The address as it was found (for orders sent with lat and lng — the address you sent). |
namephonenote | As you sent them (empty strings if not sent). |
latlng | Coordinates of the stop. |
date | Delivery date, YYYY-MM-DD. |
time_window | { "from", "to" } or null. |
service_minutes | Time at the stop in minutes, or null — then the stop gets the route's Minutes per stop. |
parcel_code | Parcel code, or null. |
signature_required | true or false. |
reviewreview_reasons | review: true — the address was found with low confidence; check it on the website. review_reasons lists why. |
source | Where the order came from: api. |
created_atupdated_at | When the order was created and last changed. |
route_idstop_id | The route and the stop the order is on, or null. |
planned_at | When the order was added to a route. |
delivered_atfailed_at | When the driver marked the stop. |
fail_reason | The driver's note when the delivery failed, or null. |
cancelled_at | When the order was cancelled. |
location | { "lat", "lng", "accuracy_m" } — where the driver marked the stop. Only when Share the driver’s position at the stop is on in Integrations; otherwise null. |
pod | Proof of delivery: { "photos": [url, …], "signature": url | null, "note": … }, or null. Photos are JPEG, the signature is SVG. Links work without the API key and are valid for 7 days — treat them as private, and fetch the order again for fresh links. |
Webhook subscriptions
A webhook sends an HTTPS request to your server when something happens. Each webhook is one event and one URL; subscribe several webhooks to get several events.
| Event | When |
|---|---|
order.created | A new order arrived in Inbox. |
order.delivered | The driver marked the order's stop as delivered. |
order.failed | The driver marked the order's stop as failed. |
route.completed | A driver finished a team route (any team route, also one without API orders). |
POST /hooks
Subscribes target_url to an event. The URL must use https and a public host name: not an IP address, not localhost or a private network, and without a user name or password (up to 2,048 characters).
curl https://courierrouteplanner.com/api/v1/hooks \
-H "Authorization: Bearer $RPT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "event": "order.delivered", "target_url": "https://example.com/webhooks/route-planner" }'
{
"hook": {
"id": "h_V2n8Rk4qLx0s",
"event": "order.delivered",
"target_url": "https://example.com/webhooks/route-planner",
"secret": "5d41a7c9e0b84f2a93c61e7d08f5b2c4a6e9d1f3b7c05a8e2d4f6b9c1e3a5d70",
"created_at": "2026-10-22T16:20:00.000Z",
"disabled": false,
"failures": 0
}
}
Store secret: it signs every delivery, and it is returned only by POST /hooks (GET /hooks doesn't show it). Sending the same event and target_url again doesn't create a duplicate: it returns the existing webhook (status 200, same secret) and turns it back on if it was disabled.
GET /hooks
Lists the team's webhooks, oldest first, without secrets. disabled: true means deliveries stopped (see Retries); failures counts failed deliveries in a row.
curl https://courierrouteplanner.com/api/v1/hooks \
-H "Authorization: Bearer $RPT_API_KEY"
{
"hooks": [
{
"id": "h_V2n8Rk4qLx0s",
"event": "order.delivered",
"target_url": "https://example.com/webhooks/route-planner",
"created_at": "2026-10-22T16:20:00.000Z",
"disabled": false,
"failures": 0
}
]
}
DELETE /hooks/{id}
Deletes a webhook; deliveries to it stop.
curl -X DELETE https://courierrouteplanner.com/api/v1/hooks/h_V2n8Rk4qLx0s \
-H "Authorization: Bearer $RPT_API_KEY"
{
"deleted": true,
"id": "h_V2n8Rk4qLx0s"
}
Webhook deliveries
Each delivery is a POST with a JSON body: a delivery id, the event, created_at, team_id and either order or route.
order.delivered{
"id": "d_Jm4cX0pQe7Ys",
"event": "order.delivered",
"created_at": "2026-10-23T10:42:31.904Z",
"team_id": "Tk7Qp2Wm9xLs",
"order": {
"id": "x_3f9c0e1d2b7a4c58e6f1a0b9c8d7e6f5a4b3c2d1",
"external_id": "SHOP-10482",
"status": "delivered",
"type": "delivery",
"date": "2026-10-23",
"service_minutes": 10,
"route_id": "Vb3kQ9xLm2PzR7tW1cYe",
"stop_id": "st_1761213600412",
"planned_at": "2026-10-22T18:03:10.215Z",
"delivered_at": "2026-10-23T10:42:28.000Z",
"failed_at": null,
"fail_reason": null,
"cancelled_at": null,
"location": {
"lat": 51.523812,
"lng": -0.158491,
"accuracy_m": 9
},
"pod": {
"photos": [
"https://courierrouteplanner.com/api/v1/media/eyJ0IjoiVGs3UXA…"
],
"signature": "https://courierrouteplanner.com/api/v1/media/eyJ0IjoiVGs3UXB…",
"note": "Left with the concierge"
},
"created_at": "2026-10-22T16:05:12.481Z"
}
}
The order in a webhook is shorter than in the API: it has no recipient name, phone, address or note. Match it by id or external_id, or fetch GET /orders/{id} for the full order.
route.completed{
"id": "d_Rt8wB3nLs1Kc",
"event": "route.completed",
"created_at": "2026-10-23T15:12:47.310Z",
"team_id": "Tk7Qp2Wm9xLs",
"route": {
"id": "Vb3kQ9xLm2PzR7tW1cYe",
"name": "North — Thursday",
"date": "2026-10-23T07:30:00.000Z",
"finished_at": "2026-10-23T15:12:44.000Z",
"stops_total": 38,
"delivered": 36,
"failed": 2,
"order_ids": [
"x_3f9c0e1d2b7a4c58e6f1a0b9c8d7e6f5a4b3c2d1",
"o_b2Vq9LmX4kPz7RtW1cYe"
]
}
}
route.date is the planned start time of the route (or null), stops_total counts the route's stops without the start, and order_ids lists the API orders on the route.
Headers
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
User-Agent | RoutePlannerTeams-Webhooks/1.0 |
X-RoutePlanner-Event | The event, for example order.delivered. |
X-RoutePlanner-Delivery | The delivery id — the same as id in the body, and the same on every retry of this delivery. Use it to skip duplicates. |
X-RoutePlanner-Signature | t=<unix time in ms>,v1=<signature> |
Verifying the signature
v1 is the hex HMAC-SHA256 of the string <t>.<raw body>, with the webhook's secret as the key. Compute it from the raw body exactly as received (before parsing the JSON), compare in constant time, and reject the request if t is more than 5 minutes away from your clock.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.RPT_WEBHOOK_SECRET; // the webhook's "secret"
const TOLERANCE_MS = 5 * 60 * 1000;
function verify(header, rawBody, secret) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || '');
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(Date.now() - t) > TOLERANCE_MS) return false; // too old or from the future
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(m[2], 'hex'));
}
// Read the body raw: the signature covers the exact bytes we sent.
app.post('/webhooks/route-planner', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(req.get('X-RoutePlanner-Signature'), raw, SECRET)) return res.status(401).end();
res.status(200).end(); // answer first, do the work afterwards
const delivery = JSON.parse(raw);
// delivery.id (= X-RoutePlanner-Delivery) is the same on retries: skip ids you have already handled.
if (delivery.event === 'order.delivered') {
// delivery.order.external_id — your order number; delivery.order.pod — proof of delivery links
}
});
app.listen(3000);
Retries
- Answer with any
2xxstatus within 8 seconds, and do slow work afterwards. - Each delivery is tried up to 3 times: right away, then 3 seconds and 10 seconds after the previous attempt. Each attempt waits up to 8 seconds for your answer.
- Timeouts, network errors,
408,429and5xxare retried; other4xxanswers are not. Redirects are not followed (a3xxcounts as a failure) — use the final URL. 410 Goneunsubscribes: the webhook is disabled at once.- After 10 failed deliveries in a row the webhook is disabled. To turn it back on, send
POST /hookswith the sameeventandtarget_url, or create a new one. - At delivery time the host must resolve to public addresses; otherwise the delivery fails.
- Deliveries are not guaranteed to arrive in order, and one may arrive twice — rely on the delivery id and on the order's
statusand times.
Proof of delivery: when photos or a signature are required at the stop, we wait a few seconds for them before sending order.delivered. Otherwise the driver's photos can arrive a few seconds after the event. If pod is null or photos are missing, fetch GET /orders/{id} a little later for the final proof of delivery.
Zapier
No developer? Connect your shop, forms or spreadsheets with Zapier. Route Planner Teams has a private Zapier integration:
- Triggers: Order Delivered, Order Failed, Route Completed.
- Action: Create Order.
- Search: Find Order.
It is not in Zapier's app directory yet: you add it with an invite link. The integration signs in with an API key from Integrations. Step by step, with the invite link and example Zaps: Connect Route Planner Teams to Zapier.
Changelog
| Version | Changes |
|---|---|
| v1 — October 2026 | First version (private beta): orders, webhooks, Zapier integration. |
The API is in beta and may still change. We'll announce changes that affect existing integrations in advance where we can. Questions and feedback: support@courierrouteplanner.com.