Plan operation endpoints
Add, edit and remove stops on an existing plan, dispatch, approve and revoke its routes, and preview what a destructive operation would cost first.
Once a plan exists, these are the calls that change it: three for its stops, three for where its routes live, and one that shows you what any of them would do before you commit.
Two facts run through all of them. Editing stops on a plan that already has routes puts it in HAS_CHANGES — the routes no longer match the stops, so you dispatch and approve again. And delivery statuses a driver has already recorded survive that re-optimization; re-running the solver never erases the morning's work.
Every endpoint on this page answers 402 subscription_required without an active subscription, 403 insufficient_scope when the key is missing the scope in the card header, and 404 plan_not_found for a plan that belongs to another team. The full list is in API error reference.
Editing stops
Adds 1–200 stops to the plan. Each entry is the same StopEntry object POST /optimize takes — including email, phone and recipientName for customer notifications.
A stop whose address cannot be geocoded is not an error here. It is saved with GEOCODE_FAILED and returned in geocodeFailedStops, so a bad row in a bulk import never rejects the other 199. Fix it later with PUT …/stops/{stopId} — where a geocode failure is fatal.
A stop that gives no way to locate itself at all — no address, no coordinates and no addressBookEntryId — is a different matter, and is refused with 400 invalid_argument. Every stop that is accepted also gets a serviceTime: leave it out and the team default is written onto the stop, so it is never missing from the stop when you read it back.
| Field | Description |
|---|---|
stops | 1–200 StopEntry objects |
matchAddressBook | Look each address up in the team address book and reuse the saved entry when it matches |
clientRequestId | Idempotency key — see Idempotent API requests |
Request
{
"stops": [
{ "name": "Lenina 5", "address": "5 Lenina St", "assignedDriverId": 93 }
],
"matchAddressBook": true,
"clientRequestId": "crm-add-8871"
}Response · 200
{
"planId": 315,
"status": "HAS_CHANGES",
"added": [
{ "id": 9020, "name": "Lenina 5", "assignedDriverId": 93 }
],
"geocodeFailedStops": [],
"deduplicated": false
}A merge patch despite the method: send only the fields you are changing, and everything you leave out keeps its current value.
A new address without coordinates is geocoded, and here a geocoding failure is an error — 400 geocode_failed, and the stop is left exactly as it was. Sending a correct address for a stop that is sitting in GEOCODE_FAILED clears the flag.
| Field | Description |
|---|---|
StopEntry fields | name, address, latitude/longitude, serviceTime, timeWindows, priority, stopSide, load, note, metadata, assignedDriverId |
email / phone / recipientName | Recipient contacts. An omitted field keeps the current value; an empty string "" clears it — there is no other way to erase a contact |
clearAssignedDriver | true unpins the stop from its driver, and takes priority over assignedDriverId in the same request |
position | New position within the route, 1-based. Neighbouring stops shift around it |
clientRequestId | Idempotency key — see Idempotent API requests |
Unlike POST /optimize and POST …/stops, this request has no legacy
arrivalRangeFrom / arrivalRangeTo fields at all: on this endpoint
timeWindows is the only way to set or change an arrival window.
Request
{
"timeWindows": [{ "from": "09:00", "to": "14:00" }],
"note": "ring twice"
}Response · 200
{
"stop": {
"id": 9012,
"timeWindows": [{ "from": "09:00", "to": "14:00" }]
},
"planStatus": "HAS_CHANGES",
"deduplicated": false
}Failures: 404 stop_not_found, 400 geocode_failed, 400 invalid_time_windows, and 400 validation_error — most often a phone number that is not in E.164 form.
Removes up to 200 stops in one call.
Stops the driver has already attempted — anything in DELIVERED or NOT_DELIVERED — are not removed. They come back in skipped with the reason already_attempted, and that is a normal 200, not an error. Check removed against what you asked for rather than assuming the whole list went.
Request
{
"stopIds": [9012, 9011],
"clientRequestId": "crm-remove-33"
}Response · 200
{
"planId": 315,
"status": "HAS_CHANGES",
"removed": [9012],
"skipped": [ { "stopId": 9011, "reason": "already_attempted" } ],
"deduplicated": false
}The MCP tool of the same name requires confirm=true before it removes anything — see Proposed changes. REST has no confirmation step: the call applies immediately.
Moving routes
Recomputes the routes of an existing plan. The body is optional — POST …/dispatch with no body at all re-optimizes with the plan's current drivers.
| Field | Description |
|---|---|
driverIds | Defaults to the drivers on the plan's current routes, or to the whole team if it has none. To hand one driver's stops to the others, send the list without them |
strategy | Defaults to BALANCED_MIX |
autoRevoke | Defaults to true. REST only — the MCP tool has no such parameter |
clientRequestId | Idempotency key — see Idempotent API requests |
{
"planId": 315,
"status": "DISPATCHING_IN_PROGRESS",
"driverIds": [91, 93],
"revokedBeforeDispatch": true,
"deduplicated": false,
"optimizationQuota": { "capacity": 34, "available": 25, "availableForApi": 13 }
}202 means the solver has started, not that routes exist. Poll GET /plans/{planId} until the status leaves DISPATCHING_IN_PROGRESS — see Plan lifecycle & statuses.
Failures: 409 dispatch_not_allowed, 400 invalid_driver_selection, 403 insufficient_scope, 409 stop_limit_exceeded, 429 concurrent_optimization_limit and 429 too_many_requests. The last two are different problems with the same status code — read code, not the status. See API limits & quotas.
Sends the routes to the drivers: DISPATCHED → APPROVED. This is the call that makes the plan appear in the driver app.
It accepts a plan in DISPATCHED and nothing else. From any other status you get 409 plan_state_transition — and for a plan in HAS_CHANGES that response is the API telling you to run POST …/dispatch first.
{
"planId": 315,
"name": "Friday deliveries",
"date": "2026-09-05",
"status": "APPROVED",
"routesCount": 2,
"stopsCount": 3
}Over MCP the same tool returns this body wrapped in an {applied, result, preview, change} envelope rather than directly — see MCP connector.
Takes the routes back from the drivers: APPROVED → DISPATCHED. The routes themselves are kept, so you can edit the plan and approve it again without paying for another optimization. The response has the same shape as approve.
Looking before you leap
Shows what a destructive operation would do, without doing it and without spending an optimization. It returns exactly the object the MCP tools hand back in place of applying a change when confirm was not passed — so there is one preview format, not two.
The mutating REST endpoints do not require a preview first. This is a voluntary look-before-you-press call.
Request
{
"operation": "REMOVE_STOPS", // CANCEL | REVOKE | REMOVE_STOPS | DISPATCH | APPROVE
"stopIds": [101, 102], // REMOVE_STOPS only
"driverIds": null, // DISPATCH only
"strategy": null // DISPATCH only
}Response · ExternalActionPreviewDto
{
"operation": "CANCEL",
"planId": 315,
"planName": "Friday deliveries",
"planStatus": "APPROVED",
"requiresConfirmation": true,
"impact": {
"routesAffected": 3,
"driversLosingRoutes": [
{ "driverId": 12, "driverName": "Maria", "pendingStops": 8, "deliveredStops": 4 }
],
"stopsAffected": 22,
"stopsSkipped": [],
"optimizationCost": 0,
"quotaAvailableForApiAfter": null
},
"warnings": [ "The plan is APPROVED: drivers lose these routes…" ],
"howToApply": "Repeat the call with confirm=true…"
}optimizationCost is non-zero only for DISPATCH, and quotaAvailableForApiAfter is filled in only then. howToApply is present only when requiresConfirmation is true. The set of warnings is fixed: cancelling an APPROVED plan, revoking, dispatching an APPROVED plan, and dispatching with the API's share of the daily quota already spent.
The same preview object is what a queued proposal stores in its diff — see Proposed changes. For creating and deleting plans rather than operating on them, see Plan endpoints.