Proposed changes
How an API key without the dispatch scope queues an operation for a human dispatcher instead of failing, and the three endpoints that create, list and apply it.
Four operations change what drivers are holding right now: dispatch, approve, revoke and cancel. Routerra calls them class D, and gives them two safety mechanisms on top of the scope check.
The first is confirmation, and it exists only on MCP: the tool returns a preview and does nothing until it is called again with confirm=true. The second is a proposal — instead of performing the operation, the API queues it for a human dispatcher to apply. That one works on both transports, and it is what this article is about.
The two-actor flow
A proposal turns "this key is not allowed to do that" into "someone who is allowed can decide". The integration describes the operation; a dispatcher in the dashboard, or a key that does hold the dispatch scope, applies it.
- CLASS D CALL goes to PROPOSED on no dispatch scope.
- CLASS D CALL goes to PROPOSED on propose: true.
- PROPOSED goes to APPLIED on dispatcher applies.
- PROPOSED goes to change_stale on plan moved on.
- PROPOSED goes to change_not_applicable on already resolved.
- PROPOSED goes to EXPIRED on 24 hours pass.
The teal path is the one you are aiming for. change_stale and change_not_applicable are both 409s at apply time, and they mean different things: stale is "the plan has changed since this was proposed, propose it again", not applicable is "this row is already applied, rejected or expired".
How a proposal gets created
A proposal is created in two ways.
Automatically, when the key has write but not dispatch. On MCP such a call no longer fails with 403; it returns applied: false and a queued row instead. This is the whole point of the mechanism: an assistant with a cautious key can still be useful.
Explicitly, with propose: true on the same four MCP tools, or with POST …/changes in REST. Any key can do this, including one that holds dispatch — you propose deliberately when you want a second pair of eyes on an operation you could have performed yourself.
A key holding only read still gets 403 insufficient_scope. It cannot even propose.
The rules
- A proposal lives 24 hours and then becomes
EXPIRED. The sweeper runs hourly, so a row can sit a little past itsexpiresAtbefore the status catches up. - Limits: 25 open proposals per plan and 100 per team, then
429 too_many_proposals. - Proposing the same operation with the same parameters on the same plan returns the row that already exists, with
deduplicated: true. - At apply time the server checks that the plan has not moved on since the proposal was made. If it has, you get
409 change_staleand have to propose again. - There is no undo. Applying a proposal is as final as making the call yourself; you roll a dispatch or an approve back the ordinary way — revoke, approve again, or cancel the plan.
- Only the four class D operations are proposable.
add_stops,update_stop,remove_stops,duplicate_planandoptimize_planalways apply immediately.
Endpoints
Queues an operation for a dispatcher. Requires write — deliberately not dispatch, since a key that already had dispatch would just perform the operation.
Request
{
"operation": "CANCEL", // DISPATCH | APPROVE | REVOKE | CANCEL
"driverIds": null, // DISPATCH only, optional
"strategy": null // DISPATCH only, optional
}Response · ExternalChangeDto
{
"id": 501,
"planId": 315,
"planName": "Friday deliveries",
"operation": "CANCEL",
"status": "PROPOSED",
"source": "REST",
"proposedBy": { "type": "API_KEY", "id": 57, "name": "CRM sync" },
"appliedBy": null, "appliedAt": null, "rejectedAt": null,
"createdAt": "2026-09-07T09:12:03Z",
"proposedAt": "2026-09-07T09:12:03Z",
"expiresAt": "2026-09-08T09:12:03Z",
"diff": { "…": "ExternalActionPreviewDto, exactly as in …/preview" },
"stale": false,
"deduplicated": false,
"howToApply": "Queued as change #501. A dispatcher can apply it…"
}diff is the same object POST …/preview returns, so there is no separate diff format to learn — see Plan operation endpoints. The raw operation payload is never shown to clients. howToApply is filled in for as long as the row is PROPOSED.
Failures: 400 change_operation_not_proposable, 429 too_many_proposals, 403 insufficient_scope, 402 subscription_required.
The team's change log: open PROPOSED rows first, then applied, rejected and expired ones, newest first. Any key can read it whatever its scope.
| Parameter | Description |
|---|---|
planId | Restrict to one plan |
status | APPLYING · PROPOSED · APPLIED · REJECTED · EXPIRED · FAILED |
limit | 1–100, default 20 |
A single row comes from GET /api/v1/external/changes/{changeId} — the same ExternalChangeDto, or 404 change_not_found if the row does not exist or belongs to another team.
Performs the proposal exactly as its diff described it. Requires the scope of the proposed operation — in practice always dispatch — plus an active subscription. It returns the updated ExternalChangeDto with status: "APPLIED", appliedBy and appliedAt.
- Idempotent by
changeId. Applying an already-applied change returns the same row withdeduplicated: trueand performs nothing a second time. NoclientRequestIdis needed here. - Applying a
DISPATCHproposal runs the solver and spends one optimize-tier token, the same as an ordinaryPOST …/dispatch.APPROVE,REVOKEandCANCELspend nothing — and neither does an attempt that cannot go ahead because the row is stale, expired, rejected or already applied. - A proposal cannot be rejected over the API. Closing someone else's request is a dashboard action only.
Failures: 404 change_not_found, 409 change_stale, 409 change_not_applicable, 403 insufficient_scope, 429 too_many_requests.
Dispatchers see the same queue in the dashboard under the plan's Pending changes panel, which is also the only place a proposal can be rejected. For how scopes are assigned in the first place, see API authentication & scopes.