Routerra LogoTeams
Browse articles· Plan endpoints
For ownersUpdated 2026-09-08 · 15 min read

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

POST/api/v1/external/optimizewritedispatch202 Accepted

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:

  1. addressBookEntryId — takes the coordinates and any settings you left blank from that address-book entry. It overrides any latitude/longitude you sent yourself: the entry's coordinates win.
  2. A name match against the address book — but only when matchAddressBook is on (it is by default) and the stop carries neither latitude nor longitude. Send either coordinate and the name match never runs.
  3. latitude and longitude — ready coordinates, geocoding skipped.
  4. 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

FieldTypeDescription
stopsStopEntry[]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
namestring ≤255Default API Plan {date}
datedateDefault: today
routeStartTimeHH:mmDeparture time; otherwise the driver's shift start
driverIdslong[]Default: every driver on the team, including ones nowhere near the stops
strategyenumBALANCED_MIX (default), BALANCE_BY_STOPS, BALANCE_BY_TIME, FASTEST_OVERALL
dispatchboolDefault true. false creates the plan only — no quota spent, no dispatch scope needed
matchAddressBookboolDefault true. Match each stop's name against the address book
clientRequestIdstring ≤128Idempotency 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.

FieldTypeDescription
namestring ≤255Required. Customer or site name; also what the address-book match runs against
addressstringGeocoded when there are no coordinates
latitude / longitudedoubleReady coordinates — skips geocoding
addressBookEntryIdlongTakes coordinates and unset settings from that entry
serviceTimeint, secondsDefault: the team's default service time
timeWindows[{from,to}], max 3Arrival windows in HH:mm. One side may be null only when there is a single window
arrivalRangeFrom / arrivalRangeToHH:mmLegacy 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
priorityenumEARLIEST, AUTO (default), LATEST — bias towards serving the stop early or late
stopSideenumLEFT, RIGHT, ANY — which kerb to park on
loadintCapacity units consumed, weighed against the driver's vehicle capacity. Default 1
assignedDriverIdlongPin the stop to one driver
notestringFree text the driver sees
emailstring ≤255Recipient e-mail, used by customer delivery notifications
phonestring ≤32Recipient phone in E.164 with a leading +; anything else is 400 validation_error
recipientNamestring ≤255The person's name, as distinct from name, which is the place
metadataJSONAny 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 valueIn the app
BALANCE_BY_STOPSBalance by Stops
BALANCE_BY_TIMEBalance by Time
FASTEST_OVERALLFastest Overall
BALANCED_MIXBalanced 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

GET/api/v1/external/plansread200

Plans across a date range, newest first.

ParameterTypeDescription
dateFrom / dateTodateDefault: −7 to +7 days around today
statusenumA single plan status. Without it, CANCELLED plans are excluded
querystringCase-insensitive fragment of the plan name
limitint1–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.

GET/api/v1/external/plans/{planId}read200

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.

ParameterTypeDescription
viewdetailed | conciseREST 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
driverIdlongOnly that driver's route; it filters failedStops too
stopStatusenumPENDING, 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

FieldTypeDescription
idnumberRoute ID
driverId, driverNamenumber, stringThe assigned driver
statusstringRoute 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
timezonestringIANA zone the …Local fields are expressed in
distancenumberRoute distance, in meters
timenumberRoute duration, in minutes
stopsCountnumberStops on this route
deliveredCount, failedCount, pendingCountnumberDelivery progress
startedAt, completedAtstringWhen the driver started and finished, ISO 8601
nextStopobjectThe stop the driver is heading to now
startLocation, finishLocationLocationWhere the route starts and ends
stopsStop[]The stops in optimized visit order — detailed only

Stop fields in a response

FieldTypeDescription
idnumberStop ID
name, addressstringAs submitted, or as taken from the address book
latitude, longitudenumberCoordinates, geocoded or as submitted
positionnumberVisit order within the route, starting at 1
expectedArrivalstringPredicted arrival, ISO 8601
expectedArrivalLocalstringThe same moment in the route's timezone
driveTimenumberDrive time from the previous point, in minutes
driveDistancenumberDrive distance from the previous point, in meters
waitTimenumberIdle time before an arrival window opens, in minutes — omitted under 5 minutes
serviceTimenumberService time in seconds. Always present: a stop created without one is stored with the team default, not left empty
statusstringPENDING, DELIVERED, or NOT_DELIVERED
errorTypestringWhy the stop is not on a route — unassigned stops only
addressBookEntryIdnumberThe address-book entry this stop resolved to or created
metadataobjectYour metadata, returned exactly as sent

Why a stop is unassigned

unassignedSummary.byErrorType counts the stops the optimizer could not place, by reason:

errorTypeMeaning
GEOCODE_FAILEDThe address could not be resolved, so the stop has no usable coordinates
CANT_VISIT_TIME_WINDOWNo driver can reach the stop inside its arrival window
CAPACITY_EXCEEDEDThe stop's load does not fit any selected driver's vehicle capacity
NO_DRIVER_WITH_ZONE_ACCESSThe stop sits in a zone none of the selected drivers may enter
MAX_JOBS_CONSTRAINTEvery driver already carries their maximum number of stops
ALLOWED_VEHICLE_CONSTRAINTThe stop's zone restricts vehicle types and no allowed vehicle was selected
OUTSIDE_TRANSIT_AREAThe coordinates fall outside the routable road network

Cancelling and copying

DELETE/api/v1/external/plans/{planId}dispatch200

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.

POST/api/v1/external/plans/{planId}/duplicatewrite201 Created

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 name

Response · 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

Was this article helpful?

Still stuck?

Write to us — a human reads every message.

Contact support