The REST API v1 endpoints of SI Delivery Routing: read and change your stops and vehicles, plan and send a day, read routes, proof of delivery and reports, and get a signed webhook when a stop changes status. The key, limits, pagination and error format are the same as for all our apps: see the API overview. The same operations are in GraphQL: GraphQL API reference: Delivery Routing.
Needs the SI Delivery Routing app. It works on a store without Rentals & Bookings. On a store without Delivery Routing, every endpoint here answers 403 with the code rentals_rest_app_not_installed and data.app: "routes".
Keys, plans and conventions #
Make a key in Delivery Routing under Settings › API. Keys belong to the store: a key made in Rentals & Bookings or in any other of our apps works here too. GET needs a read key; everything else needs a key with Allow changes (write). A read key gets 403 rentals_rest_forbidden on a write.
The API is on every plan. Three things need a higher plan and answer 403 rentals_rest_plan_required (with data.feature and data.required_plan) below it:
| Feature | Plan | Endpoints |
|---|---|---|
| Plan a day, accept a plan | Regular and up | POST /days/{date}/plan, POST /plans/{id}/accept |
| Route report | Pro and up | GET /reports/routes |
| The day’s CSV | Pro and up | GET /days/{date}/export |
The examples use two shell variables, API="https://YOUR-API-ADDRESS/api/v1" and KEY="sirb_your_secret_key". Every path starts with /api/v1/routes.
- Days are
YYYY-MM-DDin the store’s time zone, times areHH:MM, and timestamps (at,sent_at) are ISO 8601 in UTC. locationtakes a Shopify location id (801) or its gid (gid://shopify/Location/801). Without it, the store’s first depot is used.- Request bodies are JSON with snake_case names (
service_min,shift_from); camelCase names work too. - Lists of stops are paged with
pageandper_page(at most 200); the totals are in theX-TotalandX-Total-Pagesheaders. The other lists come whole.
Stops #
A stop is one delivery or collection at one address on one day, made from an order (source: "order"), a rental booking ("rental") or added by hand ("manual"). A later order to the same address and day joins the stop, so orders can list several orders.
status | Meaning |
|---|---|
needs_day | The order has no delivery day yet. |
unplanned | It has a day, but it isn’t on a saved route. |
planned | On a saved route that isn’t sent yet. |
sent | On a route sent to the driver. |
on_the_way | The driver is on the way to it. |
arrived | The driver is there. |
done | Delivered or collected. |
failed | The driver couldn’t complete it: fail_reason says why. |
cancelled | The order was cancelled, or the stop was added by hand and deleted. |
GET /api/v1/routes/stops #
Stops without a day first, then by day and slot. Paged. Cancelled stops come only when you ask for status=cancelled.
| Parameter | Notes |
|---|---|
date | One day, YYYY-MM-DD. |
status | One status or several, comma-separated: needs_day,unplanned. |
location | A depot (Shopify location). |
source | order, rental or manual. |
q | An order name (#1042) or a postcode. |
needs_day | true: only stops that still need a day. |
unrouted | true: only stops that aren’t on a route. |
curl -s "$API/routes/stops?date=2026-11-20&per_page=50" -H "Authorization: Bearer $KEY"
# 200, X-Total: 22, X-Total-Pages: 1
[{"id": 10, "source": "order", "kind": "delivery", "status": "sent", "date": "2026-11-20", "from": "08:00", "to": "12:00",
"day_source": "picker", "order_gid": "gid://shopify/Order/5550001", "order_id": 5550001, "order_name": "#1042",
"orders": [{"gid": "gid://shopify/Order/5550001", "id": 5550001, "name": "#1042"}],
"location_gid": "gid://shopify/Location/801", "area_id": 1, "name": "Jordan Lee",
"address_line": "18 Elm Street, Columbus 43215", "zip": "43215", "units": 1, "kg": 62, "m3": 0, "service_min": 10,
"geo": "shopify", "needs_pin": false, "added_by_hand": false,
"route": {"id": 1, "name": "Box truck 1", "status": "sent"},
"vehicle": {"key": "v1", "name": "Box truck 1", "colour": "#2563eb"},
"eta": {"eta": "09:15", "from": "09:00", "to": "09:30"},
"arrived_at": null, "done_at": null, "attempts": 0, "fail_reason": null,
"address": {"address1": "18 Elm Street", "city": "Columbus", "province": "Ohio", "province_code": "OH", "zip": "43215",
"country": "United States", "country_code": "US"},
"phone": "6145550100", "email": "[email protected]", "instructions": null, "note": null, "lat": 39.9612, "lng": -82.9988,
"items": [{"kg": 62, "qty": 1, "line": "gid://shopify/LineItem/13002", "name": "3-seat sofa", "order": "#1042"}]}]
| Field | Notes |
|---|---|
kind | delivery or collection. |
from, to | The time slot the customer has; null when there is none. |
day_source | Who set the day, for example picker (the customer, at checkout), staff or rental; null without a day. |
units, kg, m3 | The load, from the items. service_min is the minutes spent at the stop. |
geo, needs_pin | Where the map position came from (shopify: the order’s address, pin: set by hand); needs_pin: true when the address couldn’t be placed and needs a pin. |
route, vehicle, eta | The route the stop is on, its vehicle and the arrival estimate with the window the customer was told; null when it isn’t on a route. |
arrived_at, done_at, attempts, fail_reason | What the driver recorded. |
items | The order lines to deliver or collect. |
GET /api/v1/routes/stops/{id} #
One stop: the fields above plus events, its history, oldest first: [{at, actor, action, data}]. actor says who did it: shopify (an order change), staff:<id>, api:<key prefix> for a change through the API, or the driver, the customer or the app. 404 rentals_rest_not_found (“No such stop.”) otherwise.
curl -s "$API/routes/stops/10" -H "Authorization: Bearer $KEY"
# 200 (trimmed)
{"id": 10, "status": "sent", "date": "2026-11-20", "name": "Jordan Lee", "…": "…",
"events": [{"at": "2026-11-05T22:14:45+00:00", "actor": "shopify", "action": "created", "data": {"via": "customer", "order": "#1042"}},
{"at": "2026-11-05T22:43:18+00:00", "actor": "staff:7001", "action": "planned", "data": {"route": "Box truck 1", "from": "09:00", "to": "09:30"}}]}
GET /api/v1/routes/stops/{id}/pod #
The proof of delivery: who signed, the driver’s note, photos and the signature. The image links work for a limited time without a key. null when the driver recorded none.
curl -s "$API/routes/stops/1/pod" -H "Authorization: Bearer $KEY"
# 200
{"signed_by": "Jordan Lee", "note": null, "at": "2026-11-05T22:44:34+00:00", "lat": null, "lng": null,
"photos": [{"url": "https://YOUR-API-ADDRESS/a/routes/pod/01m6…?expires=…&signature=…", "taken_at": "2026-11-05T22:44:34+00:00"}],
"signature_url": "https://YOUR-API-ADDRESS/a/routes/pod/01m6…?expires=…&signature=…"}
POST /api/v1/routes/stops #
Adds a stop by hand (source: "manual"). name and address.address1 are required. Answers 201 with the stop, as GET /stops/{id} does.
| Body field | Notes |
|---|---|
name | Who the stop is for. Required. |
address | {company, address1, address2, city, province, zip, country, country_code}; address1 is required. |
date | YYYY-MM-DD. Without it the stop needs a day. |
from, to | A time slot, HH:MM, both or neither. |
kind | delivery (default) or collection. |
location_id | The depot it leaves from. |
phone, email | The contact the driver and the messages use. |
instructions | For the driver (gate code, floor). note is for your team. |
units, kg, m3, service_min | The load and the minutes at the stop. |
lat, lng | A map pin, when the address alone isn’t enough. |
curl -s -X POST "$API/routes/stops" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name": "Jordan Lee", "address": {"address1": "18 Elm Street", "city": "Columbus", "zip": "43215", "country_code": "US"}, "date": "2026-11-20", "from": "08:00", "to": "12:00", "kg": 40}'
# 201: the stop
# 400, without a name:
{"code": "rentals_rest_invalid", "message": "Who is the stop for?", "data": {"field": "name", "status": 400}}
PATCH or PUT /api/v1/routes/stops/{id} #
Changes a stop; only the fields you send change. Any stop can change its date, from and to, instructions, note and its pin (lat, lng); "date": null puts it back to needing a day. A stop added by hand can also change its name, contact, address and load. Answers the stop.
Refusals: 400 rentals_rest_not_movable (the stop is already with a driver or finished), 400 rentals_rest_rental_day (a rental’s delivery or collection day is changed in Rentals & Bookings), 400 rentals_rest_invalid with data.field.
curl -s -X PATCH "$API/routes/stops/10" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"date": "2026-11-21", "from": "12:00", "to": "16:00", "instructions": "Side door, ring twice"}'
DELETE /api/v1/routes/stops/{id} #
Deletes a stop added by hand: it becomes cancelled and keeps its history. Answers the stop. A stop made from an order follows its order (cancel or edit the order in Shopify), and a stop already with a driver is part of the day’s record: both answer 400 rentals_rest_not_deletable.
Routes and the day’s plan #
A route is one vehicle’s run on one day: its stops in order, with arrival estimates. The day is planned by the app’s planner, the plan is accepted as the day’s routes, and the routes are sent to the drivers. These are the same steps as Plan the day and Send to drivers in the app.
Route status | Meaning |
|---|---|
draft | Saved, not sent. |
sent | Sent to the driver. |
started | The driver started it. |
ended | Finished. |
GET /api/v1/routes/routes #
A day’s routes with their stops in order. Parameters: date (default: today), location, status (comma-separated).
curl -s "$API/routes/routes?date=2026-11-20" -H "Authorization: Bearer $KEY"
# 200
[{"id": 1, "date": "2026-11-20", "location_gid": "gid://shopify/Location/801", "name": "Box truck 1", "status": "sent",
"version": 5, "position": 1, "vehicle": {"key": "v1", "name": "Box truck 1", "colour": "#2563eb"},
"driver": {"id": 3, "name": "Sam Rivera"}, "km": 76.47, "drive_min": 131, "sent_at": "2026-11-19T22:43:21+00:00",
"stops": [{"stop_id": 3, "position": 1, "status": "sent", "eta": "07:39", "from": "07:25", "to": "07:55"},
{"stop_id": 10, "position": 2, "status": "sent", "eta": "09:15", "from": "09:00", "to": "09:30"}]}]
GET /api/v1/routes/routes/{id} #
One route, as above. 404 rentals_rest_not_found otherwise.
POST /api/v1/routes/days/{date}/plan #
Plans the day: the planner shares the day’s stops among the vehicles, in order, within their shifts and loads. Nothing changes until you accept the plan. Body (optional): location, and locks to fix stops in place: {"<stop id>": {"vehicle": "v1", "position": 1, "time": "09:00"}}. Regular plan and up.
curl -s -X POST "$API/routes/days/2026-11-20/plan" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{}'
# 200
{"id": 44, "date": "2026-11-20", "location_gid": "gid://shopify/Location/801", "version": 5, "travel": 7860, "planner_note": null,
"routes": [{"vehicle_key": "v1", "km": 76.47, "drive_min": 131,
"stops": [{"stop_id": 3, "eta": "07:39", "from": null, "to": null}, {"stop_id": 10, "eta": "09:15", "from": null, "to": null}]}],
"unassigned": [],
"totals": {"vehicles": 1, "placed": 2, "km": 76.47, "drive_min": 131}}
travel is the total driving time in seconds. unassigned lists the stops the planner couldn’t place, each with stop_id, reason and message; plan again after changing them, or lock them. Refusals: 409 rentals_rest_not_ready (“There are no stops to plan on this day.”), 400 rentals_rest_invalid_param for a date that isn’t a day.
POST /api/v1/routes/plans/{id}/accept #
Saves a plan as the day’s routes. Body (optional): version, the day’s version when it was planned (the default). If the day changed since (a new order, a stop moved), the answer is 409 rentals_rest_version_conflict: plan it again. Regular plan and up.
curl -s -X POST "$API/routes/plans/44/accept" -H "Authorization: Bearer $KEY"
# 200
{"ok": true, "plan_id": 44, "date": "2026-11-20", "location_gid": "gid://shopify/Location/801", "version": 6, "routes": [{"id": 1, "…": "…"}]}
404 rentals_rest_not_found (“No such plan.”), 409 rentals_rest_not_ready when the plan has no proposal.
POST /api/v1/routes/days/{date}/send #
Sends the day’s saved routes to the drivers (on every plan). Body (optional): location. Answers {"ok": true, "date", "location_gid", "sent", "version", "routes"}.
curl -s -X POST "$API/routes/days/2026-11-20/send" -H "Authorization: Bearer $KEY"
# 409, nothing saved yet:
{"code": "rentals_rest_not_ready", "message": "There are no saved routes to send. Accept a plan first.", "data": {"status": 409}}
GET /api/v1/routes/days/{date}/export #
The day’s stops as the CSV the Schedule page downloads, with the columns ID, Name, Address, Latitude, Longitude, From, To, Duration, Notes, Phone, Email, Types, Load, Order, Items. With format=json (or Accept: application/json) the same rows come as objects keyed by those column names, all values as strings. Parameter: location. Pro plan and up.
curl -s "$API/routes/days/2026-11-20/export?format=json" -H "Authorization: Bearer $KEY"
# 200
[{"ID": "3", "Name": "Jordan Lee #1042", "Address": "18 Elm Street, Columbus 43215, United States", "Latitude": "39.9612",
"Longitude": "-82.9988", "From": "", "To": "", "Duration": "10", "Notes": "Deliver", "Phone": "6145550100",
"Email": "[email protected]", "Types": "Box truck 1", "Load": "1", "Order": "#1042", "Items": "3-seat sofa × 1"}]
GET /api/v1/routes/reports/routes #
The route report per day and vehicle for up to 62 days (default: the last 7): stops, done, failed, km, minutes driving and on the road, stops per hour and the share on time. Parameters: from, to, location; format=csv (or Accept: text/csv) gives a CSV file. Pro plan and up.
curl -s "$API/routes/reports/routes?from=2026-11-01&to=2026-11-10" -H "Authorization: Bearer $KEY"
# 200
{"from": "2026-11-01", "to": "2026-11-10", "days": 10,
"rows": [{"date": "2026-11-07", "vehicle": "Box truck 1", "stops": 7, "done": 6, "failed": 1, "km": 76.5, "driving_min": 131,
"on_road_min": 260, "stops_per_hour": 1.6, "on_time_pct": 100}],
"total": {"date": "", "vehicle": "All vehicles", "stops": 7, "done": 6, "failed": 1, "km": 76.5, "driving_min": 131,
"on_road_min": 260, "stops_per_hour": 1.6, "on_time_pct": 100}}
Vehicles #
GET /api/v1/routes/vehicles #
The vehicles, in the order you keep them. active=true leaves out the inactive ones. source is own (made in Delivery Routing) or core (a vehicle from Rentals & Bookings › Staff & resources, changed there).
curl -s "$API/routes/vehicles" -H "Authorization: Bearer $KEY"
# 200
[{"id": 1, "key": "v1", "name": "Box truck 1", "colour": "#2563eb", "location_gid": "gid://shopify/Location/801",
"units": 10, "kg": 1500, "m3": null, "features": [], "shift_from": "07:30", "shift_to": "16:00", "active": true, "source": "own"}]
GET /api/v1/routes/vehicles/{id} #
One vehicle. 404 rentals_rest_not_found otherwise.
POST /api/v1/routes/vehicles #
Adds a vehicle. Answers 201 with the vehicle.
| Body field | Notes |
|---|---|
name | Required. |
colour | A hex colour for the map, #2563eb. |
location_id | The depot it starts from. |
units, kg, m3 | What it can carry; leave one out for no limit. |
features | Tags the planner matches with stops (for example lift). |
shift_from, shift_to | Its working hours, HH:MM. |
active | false keeps it out of planning. |
curl -s -X POST "$API/routes/vehicles" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name": "Van 3", "colour": "#7c3aed", "units": 8, "kg": 900, "shift_from": "08:00", "shift_to": "17:00"}'
Refusals: 400 rentals_rest_invalid (“Name the vehicle.”, data.field: "name"), 403 rentals_rest_vehicle_limit at your plan’s number of vehicles (Free 1, Regular 2, Pro 5, Ultimate no limit).
PATCH or PUT /api/v1/routes/vehicles/{id} #
Changes a vehicle; fields you don’t send keep their values. A vehicle from Rentals & Bookings answers 400 rentals_rest_managed_in_rentals.
DELETE /api/v1/routes/vehicles/{id} #
Deletes a vehicle: {"ok": true}. 400 rentals_rest_vehicle_in_use while it is on a route that isn’t finished.
Drivers, areas, depots, settings and slots #
Read only. Drivers, areas and settings are changed in the app.
GET /api/v1/routes/drivers #
The drivers: [{id, name, vehicle_key, active}]. Never an email, a phone number or a sign-in link.
GET /api/v1/routes/areas #
The delivery areas: [{id, name, public_name, kind, postcodes, radius_km, lat, lng, location_gid, days, window_list, day_cap, fee_hint, active, position}]. An area is a list of postcodes (ranges like 43201-43229 work), a radius around a point, or both; days lists the weekdays it is served (empty: every day).
GET /api/v1/routes/locations #
The depots, the Shopify locations the app has read: [{location_gid, name, address, zip, country_code, lat, lng, active, deleted}].
GET /api/v1/routes/settings #
The app’s settings as {"settings": {…}}, the same object the settings pages save. Map keys are never included.
GET /api/v1/routes/slots #
The time slots offered on a day: the store’s, or the delivery area’s when you give a postcode. Parameters: date (required), zip.
curl -s "$API/routes/slots?date=2026-11-20&zip=43215" -H "Authorization: Bearer $KEY"
# 200
{"date": "2026-11-20", "zip": "43215", "slots": [{"from": "08:00", "to": "12:00"}, {"from": "12:00", "to": "16:00"}, {"from": "16:00", "to": "19:00"}]}
Webhooks #
A webhook sends a signed JSON POST to your https address when something happens. A store can have up to 10.
| Topic | When |
|---|---|
stop.status_changed | A stop changes status, whoever changed it: an order, your team, the API or the driver. data.from and data.to are the statuses. |
routes.sent | A day’s routes are sent to the drivers. |
plan.accepted | A plan is accepted as the day’s routes. |
GET /api/v1/routes/webhooks #
Your webhooks: [{id, topic, url, active, created_at, last_status, last_at}]. last_status is the HTTP status of the last delivery. The secret is never shown again.
POST /api/v1/routes/webhooks #
Body: topic and url (https only, not a private network). Answers 201 with the webhook and its secret: store it now, it is shown only in this answer.
curl -s -X POST "$API/routes/webhooks" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"topic": "stop.status_changed", "url": "https://example.com/hooks/deliveries"}'
# 201
{"id": 1, "topic": "stop.status_changed", "url": "https://example.com/hooks/deliveries", "active": true,
"created_at": "2026-11-11T01:22:13+00:00", "last_status": null, "last_at": null, "secret": "whsec_3f9a…"}
Refusals: 400 rentals_rest_url_not_allowed (“The address must start with https://.”, or an address on a private network), 400 rentals_rest_too_many_webhooks (“You can have 10 webhooks. Delete one first.”), 400 rentals_rest_invalid for an unknown topic (data.field: "topic").
DELETE /api/v1/routes/webhooks/{id} #
Deletes a webhook: {"ok": true}.
What a delivery looks like #
Every delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
X-SIRoutes-Topic | The topic, stop.status_changed. |
X-SIRoutes-Delivery | A unique id for this delivery (the same on retries). |
X-SIRoutes-Timestamp | When it was sent, Unix seconds. |
X-SIRoutes-Signature | sha256= and the hex HMAC-SHA256 of the raw body, keyed with the webhook’s secret. |
User-Agent | SI-Delivery-Routing/1 |
{"id": "9b1f0c3e-6a2d-4f57-9a3e-2c8d1e4b7f10", "topic": "stop.status_changed", "shop": "{your-store}.myshopify.com",
"occurred_at": "2026-11-20T14:02:11+00:00",
"data": {"stop_id": 10, "from": "on_the_way", "to": "done", "kind": "delivery", "source": "order", "date": "2026-11-20",
"from_time": "09:00", "to_time": "09:30", "order_gid": "gid://shopify/Order/5550001", "order_name": "#1042",
"orders": [{"gid": "gid://shopify/Order/5550001", "name": "#1042"}], "location_gid": "gid://shopify/Location/801", "zip": "43215", "route_id": 1, "vehicle_key": "v1"}}
routes.sent and plan.accepted carry {date, location_gid, routes: [{id, name, vehicle_key, driver, stop_ids}]} in data, and plan.accepted adds plan_id.
Check the signature before you trust a delivery: compute the HMAC of the raw body with your secret and compare it with the header in constant time. Answer with any 2xx within 10 seconds. A delivery that fails is tried again after 1, 5, 15 and 60 minutes; after 10 deliveries in a row that finally failed, the webhook is switched off (active: false).
# Python
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers["X-SIRoutes-Signature"])
Errors #
Errors have the shape of every endpoint of the API: {"code": "rentals_rest_<code>", "message": "…", "data": {"status": …}}. The codes you will meet here:
| Status | Code | When |
|---|---|---|
| 400 | invalid_param | A parameter is malformed (date must be a day, YYYY-MM-DD.); data.params names it. |
| 400 | invalid | A body field is missing or wrong; data.field names it. |
| 400 | not_movable, not_deletable, rental_day | The stop can’t change that way (see the stop endpoints). |
| 400 | managed_in_rentals, vehicle_in_use | The vehicle is managed in Rentals & Bookings, or still on a route. |
| 400 | url_not_allowed, too_many_webhooks | Webhook address not https, or already 10 webhooks. |
| 403 | forbidden | A read key tried to change something. |
| 403 | plan_required | The feature needs a higher plan: data.feature, data.required_plan. |
| 403 | vehicle_limit | Your plan’s number of vehicles is reached. |
| 403 | app_not_installed | The store doesn’t have SI Delivery Routing (data.app: "routes"). |
| 404 | not_found | No such stop, route, vehicle, plan or webhook in this store. |
| 409 | not_ready | Nothing to plan or send on that day, or the plan has no proposal. |
| 409 | version_conflict | The day changed since it was planned: plan it again. |
| 429 | rate_limited | Too many requests: wait for Retry-After seconds. |
