Routerra LogoTeams
Browse articles· API error reference
For ownersUpdated 2026-09-08 · 9 min read

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

HTTPcodeWhen you get it
400validation_errorEvery 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
400malformed_requestThe body doesn't parse as JSON
400bad_requestAn argument failed to bind before validation ever ran. Today that is exactly one case: an unknown ?view= value on GET /plans/{planId}
400invalid_argumentOne 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
400empty_coordinatesDashboard-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
400geocode_failedPUT …/stops/{stopId} only: the new address doesn't geocode. On /optimize and …/stops this is not an error — those stops come back in geocodeFailedStops
400invalid_time_windowsOnly 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
400invalid_driver_selectionThe drivers aren't on this team, have no start location, or the list is empty
400invalid_date_range/statistics only: dateTo is earlier than dateFrom, or the range is longer than 92 days
400change_operation_not_proposablePOST …/changes with an operation outside DISPATCH, APPROVE, REVOKE and CANCEL
401invalid_api_keyThe key is missing, revoked, or unknown
402subscription_requiredA 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
403insufficient_scopeThe 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
404plan_not_found · stop_not_found · driver_not_found · team_not_found · change_not_foundNo such object on this team
405method_not_allowedThe path exists but not with this method. The response carries an Allow header and the message lists the methods that are accepted
409dispatch_not_allowedThe plan is already DISPATCHING_IN_PROGRESS, or it is APPROVED and you sent autoRevoke: false
409plan_state_transitionapprove from something other than DISPATCHED, revoke from something other than APPROVED, or cancelling a plan that is already cancelled or still busy
409plan_cancellation_blockedThe 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
409driver_route_in_progressPATCH /drivers/{driverId}: shift and capacity are locked while that driver has a route under way
409client_request_id_conflict · client_request_in_progressSee Idempotent API requests
409change_stale · change_not_applicableThe plan moved on after the change was proposed, or the proposal has expired, been rejected, or is already being applied
409stop_limit_exceededThe plan holds more stops than the team's setting allows. See API limits & quotas
415unsupported_media_typeThe request Content-Type isn't application/json
429rate_limit_exceededThe key's rate limit — either the general tier or the optimize tier. Honour Retry-After
429too_many_requestsEither 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
429too_many_proposals25 open proposals on one plan, or 100 across the team
429concurrent_optimization_limitThe 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
500internal_errorAn 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.

codeWhat ran outWhat to do
rate_limit_exceededA token bucket — the general tier or the optimize tierWait out Retry-After and retry. A steady stream inside the refill rate never trips this; bursts do
too_many_requestsToday'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 handFor 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_limitOne of the team's three parallel optimization slotsRetry when an earlier dispatch finishes
too_many_proposalsThe open-proposal ceiling — 25 per plan, 100 per teamA 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 covers date, dateFrom and dateTo.
  • Times of day — the string HH:mm, used for time windows, driver shifts and routeStartTime. It is interpreted in the team's or the route's timezone, not in yours and not in UTC. HH:mm:ss is accepted on input for routeStartTime, for a stop's arrivalRangeFrom / arrivalRangeTo and for driver shift times — but not inside timeWindows, whose from and to are pinned to a strict HH:mm. "timeWindows": [{ "from": "09:00:00" }] fails to parse and comes back as 400 malformed_request. Send HH:mm everywhere and the question never arises.
  • InstantscreatedAt, expectedArrival and statusChangedAt are OffsetDateTime values in UTC.

Units

Units are per field, not per type, which is the detail that trips integrations up most often.

FieldsUnit
Distances — driveDistance, route.distancemetres
route.time, totalTimeMinutes, driveTime, waitTimeminutes
serviceTimeseconds

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

Was this article helpful?

Still stuck?

Write to us — a human reads every message.

Contact support