Routerra LogoTeams
Browse articles· Proposed changes
For ownersUpdated 2026-09-08 · 6 min read

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.

no dispatch scopepropose: truedispatcher appliesplan moved onalready resolved24 hours passCLASS D CALLPROPOSEDAPPLIEDchange_staleEXPIREDchange_not_applicable
  • 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.
A class D operation that is proposed rather than performed

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 its expiresAt before 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_stale and 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_plan and optimize_plan always apply immediately.

Endpoints

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

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.

GET/api/v1/external/changesread200

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.

ParameterDescription
planIdRestrict to one plan
statusAPPLYING · PROPOSED · APPLIED · REJECTED · EXPIRED · FAILED
limit1–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.

POST/api/v1/external/changes/{changeId}/applydispatch200

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 with deduplicated: true and performs nothing a second time. No clientRequestId is needed here.
  • Applying a DISPATCH proposal runs the solver and spends one optimize-tier token, the same as an ordinary POST …/dispatch. APPROVE, REVOKE and CANCEL spend 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.

Was this article helpful?

Still stuck?

Write to us — a human reads every message.

Contact support