API error reference
Every error code the Routerra Teams API returns, what triggers it and which ones are worth retrying — plus the date, time, unit and phone formats it expects.
Every failure from the external API comes back as the same small JSON object, whatever went wrong:
{
"code": "insufficient_scope",
"message": "Operation 'dispatch' requires the DISPATCH scope. This key has: READ, WRITE."
}
code is stable and meant for your code to branch on. message is written for a human reading a log and can change without notice — never match on it. The codes below are the ones the external layer actually returns.
Error codes
| HTTP | code | When you get it |
|---|---|---|
| 400 | validation_error | Every bean-validation or binding failure: an invalid body field, a query parameter outside its range, a type mismatch, a phone number that isn't E.164. This is the code you get far more often than invalid_argument |
| 400 | malformed_request | The body doesn't parse as JSON |
| 400 | bad_request | An argument failed to bind before validation ever ran. Today that is exactly one case: an unknown ?view= value on GET /plans/{planId} |
| 400 | invalid_argument | One narrow exception type, raised from exactly five places: a bad ISO date or time over MCP, a required MCP tool argument that is missing, an unknown addressBookEntryId, a stop with no address and no coordinates, and an unsupported operation on POST …/preview |
| 400 | empty_coordinates | Dashboard-only in practice. The code exists, but every path that raises it is an internal one — no external endpoint reaches it. A stop with no usable location comes back as invalid_argument or geocode_failed instead |
| 400 | geocode_failed | PUT …/stops/{stopId} only: the new address doesn't geocode. On /optimize and …/stops this is not an error — those stops come back in geocodeFailedStops |
| 400 | invalid_time_windows | Only when you send two or more windows: they overlap, there are more than three, one is missing a bound, or one is zero-length or overnight. A single window keeps every legacy freedom — an open bound, a zero-length window, and an overnight window whose to is earlier than its from are all accepted |
| 400 | invalid_driver_selection | The drivers aren't on this team, have no start location, or the list is empty |
| 400 | invalid_date_range | /statistics only: dateTo is earlier than dateFrom, or the range is longer than 92 days |
| 400 | change_operation_not_proposable | POST …/changes with an operation outside DISPATCH, APPROVE, REVOKE and CANCEL |
| 401 | invalid_api_key | The key is missing, revoked, or unknown |
| 402 | subscription_required | A write on a team whose subscription is inactive or expired. A team with no subscription record at all (legacy and dev teams) is not blocked. Reads and …/preview always work |
| 403 | insufficient_scope | The key lacks the scope the operation requires. The message names the operation, the scope needed, and the scopes the key actually holds. Retrying is pointless — a team owner has to grant the scope |
| 404 | plan_not_found · stop_not_found · driver_not_found · team_not_found · change_not_found | No such object on this team |
| 405 | method_not_allowed | The path exists but not with this method. The response carries an Allow header and the message lists the methods that are accepted |
| 409 | dispatch_not_allowed | The plan is already DISPATCHING_IN_PROGRESS, or it is APPROVED and you sent autoRevoke: false |
| 409 | plan_state_transition | approve from something other than DISPATCHED, revoke from something other than APPROVED, or cancelling a plan that is already cancelled or still busy |
| 409 | plan_cancellation_blocked | The day has already started — the plan has DELIVERED or NOT_DELIVERED stops, or a route has left OPTIMIZED, meaning IN_PROGRESS or already COMPLETED. A finished route blocks cancellation exactly as a running one does |
| 409 | driver_route_in_progress | PATCH /drivers/{driverId}: shift and capacity are locked while that driver has a route under way |
| 409 | client_request_id_conflict · client_request_in_progress | See Idempotent API requests |
| 409 | change_stale · change_not_applicable | The plan moved on after the change was proposed, or the proposal has expired, been rejected, or is already being applied |
| 409 | stop_limit_exceeded | The plan holds more stops than the team's setting allows. See API limits & quotas |
| 415 | unsupported_media_type | The request Content-Type isn't application/json |
| 429 | rate_limit_exceeded | The key's rate limit — either the general tier or the optimize tier. Honour Retry-After |
| 429 | too_many_requests | Either the daily optimization quota (or the API's share of it) is spent for today, or the optimize-tier bucket was empty on a call that takes its token by hand — optimize_plan, dispatch_plan, apply_change. The message says which; the first is an exhausted budget, the second a burst limit |
| 429 | too_many_proposals | 25 open proposals on one plan, or 100 across the team |
| 429 | concurrent_optimization_limit | The team already has three optimizations running. This is not a 409: conflict-shaped as it is, the refusal arrives in the 429 family. Retry once an earlier dispatch finishes |
| 500 | internal_error | An unexpected failure. The exception text is never exposed |
Four different reasons for a 429
A 429 does not mean one thing. Four separate limiters answer with that status, and the right response to each is different.
code | What ran out | What to do |
|---|---|---|
rate_limit_exceeded | A token bucket — the general tier or the optimize tier | Wait out Retry-After and retry. A steady stream inside the refill rate never trips this; bursts do |
too_many_requests | Today's optimization quota, or the share of it API keys are allowed to spend — or the optimize-tier bucket, when the caller took its token by hand | For the quota, retrying sooner will not help; the budget resets on the next day. For the bucket, wait a few seconds. Read the message |
concurrent_optimization_limit | One of the team's three parallel optimization slots | Retry when an earlier dispatch finishes |
too_many_proposals | The open-proposal ceiling — 25 per plan, 100 per team | A dispatcher has to apply or reject queued proposals before you can add more |
The mechanics behind each of these — bucket capacities, refill rates, the dashboard's reserved share of the daily quota — are in API limits & quotas.
Dates, times and units
- Dates — ISO
yyyy-MM-dd. That coversdate,dateFromanddateTo. - Times of day — the string
HH:mm, used for time windows, driver shifts androuteStartTime. It is interpreted in the team's or the route's timezone, not in yours and not in UTC.HH:mm:ssis accepted on input forrouteStartTime, for a stop'sarrivalRangeFrom/arrivalRangeToand for driver shift times — but not insidetimeWindows, whosefromandtoare pinned to a strictHH:mm."timeWindows": [{ "from": "09:00:00" }]fails to parse and comes back as400 malformed_request. SendHH:mmeverywhere and the question never arises. - Instants —
createdAt,expectedArrivalandstatusChangedAtareOffsetDateTimevalues in UTC.
Units
Units are per field, not per type, which is the detail that trips integrations up most often.
| Fields | Unit |
|---|---|
Distances — driveDistance, route.distance | metres |
route.time, totalTimeMinutes, driveTime, waitTime | minutes |
serviceTime | seconds |
serviceTime is the odd one out: a 15-minute drop-off is 900, not 15.
Phone numbers
Phone numbers are E.164 with the leading + — +13475550142. Any other format is refused with 400 validation_error. This applies to a stop's phone, to the merge-patch on a stop, and to address-book entries.
What's next
- API limits & quotas — the four limiters behind a
429 - Idempotent API requests —
clientRequestId, and the two409s it can raise - Plan lifecycle & statuses — which transitions produce
plan_state_transition - Response objects & enums — the field names the errors above refer to