Plan endpoints
POST /optimize in full — the four ways to specify a stop, dispatch=false, geocode failures and the 202 poll — plus listing, reading, cancelling and copying plans.
These five endpoints are the spine of the API. One creates a plan and starts the solver, two read plans back, one cancels, one copies. If you only ever call one endpoint, it is POST /optimize.
All paths are relative to the production base URL:
https://teams-api.routerra.io/api/v1/external
Optimization is asynchronous everywhere. A call that starts the solver answers 202 immediately with a planId, and you poll GET /plans/{planId} until the status leaves DISPATCHING_IN_PROGRESS — usually 5 to 30 seconds. Plan lifecycle & statuses is the state machine behind that loop.
Creating and optimizing a plan
One call creates the plan, adds your stops and starts optimization across the drivers you selected.
How a stop is located
Each stop is resolved by the first of these that applies, in order:
addressBookEntryId— takes the coordinates and any settings you left blank from that address-book entry. It overrides anylatitude/longitudeyou sent yourself: the entry's coordinates win.- A name match against the address book — but only when
matchAddressBookis on (it is by default) and the stop carries neitherlatitudenorlongitude. Send either coordinate and the name match never runs. latitudeandlongitude— ready coordinates, geocoding skipped.address— geocoded on the server.
A stop that reaches the end of that list with nothing to go on — no addressBookEntryId, no name match, no coordinates and no address — is refused with 400 invalid_argument, and that refusal fails the whole request, not just the one stop. A name-only stop is therefore safe exactly as far as the address book is: it resolves silently when the name is on file, and takes the entire call down with it when it is not.
An address the geocoder cannot resolve does not fail the request. The stop is saved with errorType: "GEOCODE_FAILED", listed in the response's geocodeFailedStops, and the solver routes everything else. If not a single address resolves, the plan lands in CREATED instead of DISPATCHING_IN_PROGRESS.
That tolerance belongs to POST /optimize and POST …/stops only. PUT …/stops/{stopId} geocodes strictly: a new address that will not resolve is 400 geocode_failed and the stop is left exactly as it was.
Request fields
| Field | Type | Description |
|---|---|---|
stops | StopEntry[] | Required. At least one. There is no upper bound in the body itself — the ceiling is the team's stop limit (200 unless changed), and going over it is 409 stop_limit_exceeded. See the table below |
name | string ≤255 | Default API Plan {date} |
date | date | Default: today |
routeStartTime | HH:mm | Departure time; otherwise the driver's shift start |
driverIds | long[] | Default: every driver on the team, including ones nowhere near the stops |
strategy | enum | BALANCED_MIX (default), BALANCE_BY_STOPS, BALANCE_BY_TIME, FASTEST_OVERALL |
dispatch | bool | Default true. false creates the plan only — no quota spent, no dispatch scope needed |
matchAddressBook | bool | Default true. Match each stop's name against the address book |
clientRequestId | string ≤128 | Idempotency key — a repeat with the same id returns the existing plan with deduplicated: true |
Leaving driverIds unset is the most common way to get a plan that fails. The default is the whole team, and a single driver whose start point is in another city turns the entire plan DISPATCH_FAILED — read GET /team and choose deliberately, as Team, driver & analytics endpoints explains.
clientRequestId is what makes a retry after a timeout safe. Send the same id and you get the same plan back rather than a second one; Idempotent API requests covers what the replayed response does and does not reflect.
StopEntry
The same shape is used by POST /plans/{planId}/stops.
| Field | Type | Description |
|---|---|---|
name | string ≤255 | Required. Customer or site name; also what the address-book match runs against |
address | string | Geocoded when there are no coordinates |
latitude / longitude | double | Ready coordinates — skips geocoding |
addressBookEntryId | long | Takes coordinates and unset settings from that entry |
serviceTime | int, seconds | Default: the team's default service time |
timeWindows | [{from,to}], max 3 | Arrival windows in HH:mm. One side may be null only when there is a single window |
arrivalRangeFrom / arrivalRangeTo | HH:mm | Legacy single-window pair, accepted here and on POST …/stops. Send it together with timeWindows and timeWindows wins: the pair is overwritten with a mirror of the first window |
priority | enum | EARLIEST, AUTO (default), LATEST — bias towards serving the stop early or late |
stopSide | enum | LEFT, RIGHT, ANY — which kerb to park on |
load | int | Capacity units consumed, weighed against the driver's vehicle capacity. Default 1 |
assignedDriverId | long | Pin the stop to one driver |
note | string | Free text the driver sees |
email | string ≤255 | Recipient e-mail, used by customer delivery notifications |
phone | string ≤32 | Recipient phone in E.164 with a leading +; anything else is 400 validation_error |
recipientName | string ≤255 | The person's name, as distinct from name, which is the place |
metadata | JSON | Any object you like — order IDs, customer references — stored and returned untouched |
Plan and stop responses express arrival windows as timeWindows, with one exception: GET /address-book returns the legacy arrivalRangeFrom / arrivalRangeTo pair and no timeWindows at all. In the other direction, PUT …/stops/{stopId} has no arrivalRange* fields, so on that endpoint timeWindows is the only way to set a window.
Distribution strategies
The same four options as the Assign drivers & optimize dialog. Optimize routes explains when to pick which.
| API value | In the app |
|---|---|
BALANCE_BY_STOPS | Balance by Stops |
BALANCE_BY_TIME | Balance by Time |
FASTEST_OVERALL | Fastest Overall |
BALANCED_MIX | Balanced Mix |
Request
{
"name": "Friday deliveries", "date": "2026-09-05",
"routeStartTime": "08:30", "driverIds": [91, 93],
"clientRequestId": "crm-order-batch-4412",
"stops": [
{ "name": "Dr. Lee Clinic" },
{ "name": "Warehouse B", "address": "12 Main St, Brooklyn, NY",
"timeWindows": [{ "from": "09:00", "to": "11:00" }],
"serviceTime": 900,
"email": "ops@warehouse-b.example", "phone": "+13475550142",
"metadata": { "orderId": "A-118" } },
{ "name": "Pickup", "latitude": 40.7128, "longitude": -74.006,
"assignedDriverId": 93 }
]
}Response · 202
{
"planId": 315,
"status": "DISPATCHING_IN_PROGRESS",
"stopsCount": 3,
"createdAt": "2026-09-07T10:12:04Z",
"deduplicated": false,
"geocodeFailedStops": [],
"similarPlanIds": [311]
}
// status = "CREATED" when dispatch=false,
// or when no address resolved at all.
// similarPlanIds — plans with the same name and
// date: a hint that you may be making a duplicate.The first stop in that request carries nothing but a name: it resolves through the address book. The second is geocoded from its address. The third supplies coordinates and pins itself to a driver.
geocodeFailedStops is the field to check on every response. It is empty on a clean run and holds the stops that were saved without usable coordinates otherwise — those stops exist in the plan, they are simply not on any route. They show up again in GET /plans/{planId} under unassignedSummary.byErrorType as GEOCODE_FAILED.
Errors: 400 validation_error, 400 invalid_argument, 400 invalid_time_windows, 400 invalid_driver_selection, 402 subscription_required, 403 insufficient_scope, 409 stop_limit_exceeded, 429 concurrent_optimization_limit, 429 too_many_requests. All four 429s in the API mean different things and want different retry strategies — branch on code, never on the status, as API limits & quotas sets out.
Reading plans back
Plans across a date range, newest first.
| Parameter | Type | Description |
|---|---|---|
dateFrom / dateTo | date | Default: −7 to +7 days around today |
status | enum | A single plan status. Without it, CANCELLED plans are excluded |
query | string | Case-insensitive fragment of the plan name |
limit | int | 1–100, default 50 |
{
"id": 315, "name": "Friday deliveries", "date": "2026-09-05", "status": "DISPATCHED",
"stopsCount": 3, "routesCount": 2, "createdAt": "…", "updatedAt": "…"
}There is no pagination — only limit and a narrower date range. To export a long history, walk it day by day.
The plan's state and the optimization result: routes per driver, delivery progress, the next stop, failed and unassigned stops. This is the endpoint you poll after a 202 from /optimize or …/dispatch, until the status leaves DISPATCHING_IN_PROGRESS.
A cancelled plan is still returned here, with status: "CANCELLED" — it is not a 404.
| Parameter | Type | Description |
|---|---|---|
view | detailed | concise | REST defaults to detailed, MCP to concise. concise drops routes[].stops and the full unassignedStops, keeping unassignedSummary — a 200-stop plan fits in about 3 KB. Any other value is 400 bad_request |
driverId | long | Only that driver's route; it filters failedStops too |
stopStatus | enum | PENDING, DELIVERED, NOT_DELIVERED — filters the stops inside routes, in detailed only |
{
"id": 315, "name": "Friday deliveries", "date": "2026-09-05", "routeStartTime": "08:30:00",
"status": "DISPATCHED", "hasUnoptimizedChanges": false,
"stopsCount": 3, "routesCount": 2, "totalTimeMinutes": 148, "totalDistanceMeters": 31240,
"createdAt": "…", "updatedAt": "…",
"routes": [ {
"id": 802, "driverId": 91, "driverName": "Maya R.", "status": "IN_PROGRESS",
"timezone": "America/New_York", "distance": 18400, "time": 92, "stopsCount": 2,
"deliveredCount": 1, "failedCount": 0, "pendingCount": 1,
"startedAt": "2026-09-05T12:31:00Z", "completedAt": null,
"nextStop": { "id": 9012, "name": "Warehouse B", "position": 2,
"expectedArrival": "2026-09-05T14:05:00Z", "expectedArrivalLocal": "10:05" },
"startLocation": { "address": "…", "latitude": 40.73, "longitude": -73.95 }
} ],
"unassignedSummary": {
"total": 1,
"byErrorType": [ { "errorType": "GEOCODE_FAILED", "count": 1 } ],
"preview": [ { "id": 9014, "name": "Ghost", "address": "asdfgh 99999", "errorType": "GEOCODE_FAILED" } ]
},
"failedStops": [ { "id": 9011, "name": "Dr. Lee Clinic", "routeId": 803, "driverName": "Danny L.",
"failureReason": "BUSINESS_CLOSED", "note": "closed till 2pm",
"statusChangedAtLocal": "09:42" } ]
}
// detailed adds routes[].stops[] in visit order and the full
// unassignedStops[]. Fields with no value are omitted.
// DISPATCH_FAILED adds "dispatchFailureReason".routes is present only once optimization has produced something — in DISPATCHED, APPROVED, HAS_CHANGES and DISPATCH_FAILED. For a plan in CREATED, the stops are in unassignedStops and unassignedSummary instead.
failedStops are the stops a driver marked NOT_DELIVERED in the app, with their reason and the driver's note. statusChangedAtLocal and the other …Local fields are in the route's own timezone, which is the one to show a human; the plain fields are UTC.
Route fields
| Field | Type | Description |
|---|---|---|
id | number | Route ID |
driverId, driverName | number, string | The assigned driver |
status | string | Route progress: OPTIMIZED, IN_PROGRESS, COMPLETED. There is no PENDING route status — OPTIMIZED is the value a route carries before the driver starts it, and it is the only status from which the plan can still be cancelled |
timezone | string | IANA zone the …Local fields are expressed in |
distance | number | Route distance, in meters |
time | number | Route duration, in minutes |
stopsCount | number | Stops on this route |
deliveredCount, failedCount, pendingCount | number | Delivery progress |
startedAt, completedAt | string | When the driver started and finished, ISO 8601 |
nextStop | object | The stop the driver is heading to now |
startLocation, finishLocation | Location | Where the route starts and ends |
stops | Stop[] | The stops in optimized visit order — detailed only |
Stop fields in a response
| Field | Type | Description |
|---|---|---|
id | number | Stop ID |
name, address | string | As submitted, or as taken from the address book |
latitude, longitude | number | Coordinates, geocoded or as submitted |
position | number | Visit order within the route, starting at 1 |
expectedArrival | string | Predicted arrival, ISO 8601 |
expectedArrivalLocal | string | The same moment in the route's timezone |
driveTime | number | Drive time from the previous point, in minutes |
driveDistance | number | Drive distance from the previous point, in meters |
waitTime | number | Idle time before an arrival window opens, in minutes — omitted under 5 minutes |
serviceTime | number | Service time in seconds. Always present: a stop created without one is stored with the team default, not left empty |
status | string | PENDING, DELIVERED, or NOT_DELIVERED |
errorType | string | Why the stop is not on a route — unassigned stops only |
addressBookEntryId | number | The address-book entry this stop resolved to or created |
metadata | object | Your metadata, returned exactly as sent |
Why a stop is unassigned
unassignedSummary.byErrorType counts the stops the optimizer could not place, by reason:
errorType | Meaning |
|---|---|
GEOCODE_FAILED | The address could not be resolved, so the stop has no usable coordinates |
CANT_VISIT_TIME_WINDOW | No driver can reach the stop inside its arrival window |
CAPACITY_EXCEEDED | The stop's load does not fit any selected driver's vehicle capacity |
NO_DRIVER_WITH_ZONE_ACCESS | The stop sits in a zone none of the selected drivers may enter |
MAX_JOBS_CONSTRAINT | Every driver already carries their maximum number of stops |
ALLOWED_VEHICLE_CONSTRAINT | The stop's zone restricts vehicle types and no allowed vehicle was selected |
OUTSIDE_TRANSIT_AREA | The coordinates fall outside the routable road network |
Cancelling and copying
Soft-cancels the plan. The routes come down and drivers stop seeing it at once, but the plan, its stops and its proof-of-delivery photos are kept and the status becomes CANCELLED.
A plan that is still in DISPATCHING_IN_PROGRESS cannot be cancelled at all: the call is refused with 409 plan_state_transition and a message telling you to wait for DISPATCHED first. Cancelling is not a way out of an optimization that is taking too long.
{
"planId": 315, "name": "Friday deliveries",
"statusBeforeCancellation": "APPROVED", "cancelled": true,
"status": "CANCELLED", "restorable": true
}Cancellation is refused once the day has started — any stop in DELIVERED or NOT_DELIVERED, or any route in IN_PROGRESS or COMPLETED — with 409 plan_cancellation_blocked, whose message names the numbers and points you at removing the remaining stops instead. A plan that is merely busy is a different refusal: 409 plan_state_transition, which means wait and retry. Plan lifecycle & statuses has both cases in full.
Errors: 404 plan_not_found, 409 plan_cancellation_blocked, 409 plan_state_transition, 403 insufficient_scope, 402 subscription_required.
Copies a plan onto another date: all the stops with all their settings, including the ones whose addresses never resolved, and none of the routes or delivery statuses. The copy comes back in CREATED — dispatch it when you are ready.
Request
{ "date": "2026-09-06", "name": "Saturday (copy of Friday)" }
// name is optional — it defaults to the source plan's nameResponse · 201
{
"id": 316, "name": "Saturday (copy of Friday)", "date": "2026-09-06",
"status": "CREATED", "stopsCount": 3, "routesCount": null,
"createdAt": "…", "updatedAt": "…"
}This is the API side of Duplicate a plan. Because it needs only the write scope, it is the cheapest way for a read-and-write integration to set up tomorrow's work without ever holding the dispatch scope.
What's next
- Plan operation endpoints — add and remove stops, dispatch, approve, revoke
- Plan lifecycle & statuses — the state machine your polling loop keys off
- Idempotent API requests — retrying a timed-out
/optimizesafely - API limits & quotas — the four different
429s and how to back off from each