The plan editor
The plan editor
The plan editor of the map SDKs lets a person edit a plan of routes on a map. Every edit is one call of the plan calls with the whole plan, and the answer replaces the routes, distances, times and violations shown. The plan is never stored on a server: your page or app receives it back from the editor's events. The editor is part of every map SDK from version 1.1.6.
This guide sets one up in four steps: a credential from your server, the editor on a map, its events, and its errors.
1. What the editor does
- Drag a stop within its route to give the route a new order (
plan.resequence), or onto another route or a vehicle's start to move it there (plan.move). While a stop is dragged, the plan with the stop at the target is priced and shown as a preview. - Remove stops from their routes (
plan.remove). They join the unassigned jobs. - Insert new jobs, or unassigned jobs, at the positions that add the least time or distance (
plan.insert). The answered positions are offered best first; another one can be chosen. - Refine routes around locked stops (
plan.refine). The new order is kept only when it is better. - Undo and redo every edit. A plan already priced is shown again without a call.
- The ready-made panel: the totals and each route's distance, driving time, stops and violations, the stops with
their arrival, Lock and Remove, the unassigned jobs with Place, the insert positions with Choose, Undo,
Redo and Refine, and the last error. It is in 14 languages and is a map tool with the id
plan-editor(sMap integration 14).
An edit you can see the outcome of (a move, a removal, a new order) is drawn at once and priced when the answer arrives. A newer edit cancels the call before it. An edit the service refuses is taken back and reported.
The editor opens a distance snapshot (POST /v1/plan/matrix) and names it in every call, so legs measured once are
not measured again. When the snapshot expires it opens a new one; a snapshot that cannot be opened never stops an
edit.
The fast path
An account can have its plan edits answered by the map service nearest to the user instead of the Open Platform: in the client portal (/open-api), set Plan editing path to Fast. Your code does not change. The plan editor token answer and the map key's configuration then carry fast_path.url, and the editor sends its plan calls there. Answers, errors and billing are the same as on /v1/plan/*. When the fast path cannot answer a call (409 USE_STANDARD_PATH, a server error or no answer in time), the editor repeats the call on /v1/plan/* by itself. A map key takes the fast path only while the account holds map tiles (map.tiles).
New points measured at once
When a stop is dropped on the map (on release, never while dragging) or an address is picked in the panel's insert form, the editor sends the new point to POST /v1/plan/matrix/{matrix}/points on the Open Platform (/v1/plan/*, never the fast path), with the body {"points": [{"lat": 45.5231, "lng": -73.5817}]}. The road legs between that point and the plan's points are measured into the snapshot at once, in about a second, and the call answers at once with queued. When the insert is confirmed, the editor waits up to one second for those legs (wait_ms: 1000, answered with ready), then sends plan.insert either way: on the fast path when the legs are held, otherwise on the standard path. The call is free. Its points never make it fail: entries that are not a coordinate are left out and at most 50 points are used per call. An unknown or expired snapshot answers 410 RESOURCE_EXPIRED, and the editor opens a new one.
2. Step 1: a credential from your server
The editor runs in a browser or an app, so it never holds your API token. It uses one of two credentials, both sent
as Authorization: Bearer …:
| Credential | Obtained | Works from |
|---|---|---|
Plan editor token sr_pet_… |
Your server calls POST /v1/plan/token with the API token |
The origins given when it was issued; anywhere when none were given |
Map key smk_… |
Allow plan editing switched on for the key on the Map keys page of the client portal (/open-api/map-keys) |
The websites and apps on the key's allowlist |
2.1 A plan editor token
Add an address to your own server that issues a token and hands the answer on:
curl -X POST https://open.superroute.ca/v1/plan/token \
-H "Authorization: Bearer $SUPERROUTE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ttl_seconds": 900, "origins": ["dispatch.example.com"]}'
{
"data": {
"access_token": "sr_pet_…",
"token_type": "Bearer",
"expires_in": 900,
"expires_at": "2026-10-28T09:42:39-04:00",
"scopes": ["plan.*"],
"origins": ["dispatch.example.com"]
}
}
The same in a Node.js server:
app.get('/plan-token', async (req, res) => {
const r = await fetch('https://open.superroute.ca/v1/plan/token', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.SUPERROUTE_API_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ ttl_seconds: 900, origins: ['dispatch.example.com'] }),
});
res.status(r.status).json((await r.json()).data);
});
ttl_seconds: 60 to 3600, default 900. A longer life is shortened to 3600.origins: up to 20 hosts or*.domainpatterns. A call from another page is refused. Leave it out for a token used by a native app.- The token carries the plan capabilities of the API key. Revoking the key, narrowing it, or a change of the account's capabilities ends the token. A token cannot issue another token.
- The editor takes a function that fetches the token from your server. It calls it at first use, again 30 seconds
before
expires_at, and once more after a401 SESSION_EXPIRED. The function may return the token text or the wholedataobject above.
2.2 A map key with plan editing
On the Map keys page, switch on Allow plan editing for the key and list your website or app on its allowlist. Plan editing is off for every key until it is switched on. The key works for the plan calls only while the account holds the plan capabilities, and it is never renewed. Native apps send their identity with every call, read from the operating system as for map requests.
3. Step 2: the editor on a map
Install the SDK as described in sMap integration 4. Each example below creates
the editor with the ready-made panel; plan is the plan you hold:
const plan = {
region: 'NA', profile: 'car',
vehicles: [{ id: 'van-1', start: { lat: 45.5017, lng: -73.5673 }, return_to_start: true }],
jobs: [
{ id: 'order-1001', location: { lat: 45.5088, lng: -73.554 }, service_seconds: 300 },
{ id: 'order-1002', location: { lat: 45.4972, lng: -73.5786 }, service_seconds: 300 },
],
routes: [{ vehicle_id: 'van-1', stops: ['order-1001', 'order-1002'] }],
};
Every vehicle and job needs an id. The fields are those of the plan calls
(Editing plans).
Web
const map = new sMap.Map({ container: 'map', apiKey: 'smk_…', style: 'light' });
const editor = map.planEditor({
credentials: { token: () => fetch('/plan-token').then((r) => r.json()) }, // or { key: 'smk_…' }
plan,
language: 'fr', // the panel's language; default the map's
});
The routes are drawn as the layers plan:legs, plan:starts, plan:stops and plan:unassigned (id changes the
prefix, colors the route colours). panel: { position: 'top-right' } moves the panel, panel: false leaves it out,
and labels replaces any of its texts. new sMap.PlanEditor(options) is the same editor without a map.
React
import { SMap, PlanEditor } from '@superrouteca/smap-react';
export function Dispatch({ initialPlan }) {
const [plan, setPlan] = useState(initialPlan);
return (
<SMap apiKey="smk_…" style="light">
<PlanEditor credentials={{ token: getPlanToken }} plan={plan} language="de"
onChange={(e) => { if (!e.pending) setPlan(e.plan); }} onError={(err) => report(err.code)} />
</SMap>
);
}
Handing the plan of onChange back as plan is not a change. usePlanEditor(props) gives the editor and its state
for an interface of your own.
Leaflet
import { smapPlanEditor } from '@superrouteca/smap-leaflet';
const editing = smapPlanEditor({
credentials: { token: () => fetch('/plan-token').then((r) => r.json()) },
plan,
panel: { position: 'topright' },
}).addTo(map);
editing.editor.on('change', (e) => save(e.plan));
Stops are Leaflet markers: drag a pin onto another stop or onto a route's line. The panel is a Leaflet control.
OpenLayers
import { addPlanEditor } from '@superrouteca/smap-openlayers';
const editing = addPlanEditor(map, { credentials: { token: () => fetch('/plan-token').then((r) => r.json()) }, plan });
editing.editor.on('change', (e) => save(e.plan));
editing.layer is a vector layer, editing.interaction the drag, editing.control the panel.
Mapbox GL JS
import { addPlanEditor } from '@superrouteca/smap-mapbox';
const editing = addPlanEditor(map, { credentials: { token: () => fetch('/plan-token').then((r) => r.json()) }, plan });
editing.editor.on('change', (e) => save(e.plan));
The plan is added as sources and layers named plan:… once the style has loaded (editing.ready); before puts
them under a layer of your style.
deck.gl
import { addPlanEditor } from '@superrouteca/smap-deckgl';
const editing = addPlanEditor(deck, { credentials: { token: () => fetch('/plan-token').then((r) => r.json()) }, plan });
editing.editor.on('change', (e) => save(e.plan));
The plan's layers join the deck's own layers; onLayers(layers) places them yourself.
ArcGIS Maps SDK for JavaScript
import { addPlanEditor } from '@superrouteca/smap-arcgis';
const editing = addPlanEditor(view, { credentials: { token: () => fetch('/plan-token').then((r) => r.json()) }, plan });
editing.editor.on('change', (e) => save(e.plan));
The plan is one graphics layer on the view's map, and the panel is added to the view's UI.
CesiumJS
import { addPlanEditor } from '@superrouteca/smap-cesium';
const editing = addPlanEditor(viewer, { credentials: { token: () => fetch('/plan-token').then((r) => r.json()) }, plan });
editing.editor.on('change', (e) => save(e.plan));
The plan is a data source on the ground; the panel sits top left, beside the viewer's own toolbar.
In every adapter, editor: myEditor binds an editor you made instead, remove() takes the plan off the map, and
destroying the editor removes everything.
Android
val editor = map.planEditor(SMapPlanEditorOptions(
credentials = SMapPlanCredentials.tokenAnswer { myBackend.fetchPlanToken() }, // the token answer as JSON text
plan = SMapPlan.fromJson(planJson),
))
editor.addListener { e -> if (e is SMapPlanEvent.Change && !e.pending) save(e.plan!!.toJson()) }
SMapPlanCredentials.token { … } takes the token text, SMapPlanCredentials.key("smk_…") a map key. A stop is
dragged after a long press. The edits are suspend functions (editor.move(listOf("job-7"), "van-2", position = 1)).
iOS
import SMap
let options = PlanEditorOptions(credentials: .token {
let (data, _) = try await URLSession.shared.data(from: URL(string: "https://my.server/plan-token")!)
guard let token = PlanToken(json: try SMapJSON(data: data)) else { throw PlanError("no token") }
return token
}, plan: try SMapPlan(jsonObject: planJSON))
let editing = map.planEditor(options)
editing.editor.on { event in
if case .change(let c) = event, !c.pending, let plan = c.plan { save(plan.jsonObject) }
}
.key("smk_…") takes a map key. A stop is dragged after a long press; editing.destroy() ends the editor.
macOS
import SMapMac
let editing = map.planEditor(PlanEditorOptions(credentials: .token { try await myServer.planToken() }, plan: plan))
editing.editor.on { event in
if case .change(let c) = event, !c.pending, let plan = c.plan { save(plan.jsonObject) }
}
The same editor as on iOS. Stops are dragged with the pointer, and the panel is made of the system's own controls.
Flutter
final controller = SMapController();
late final editing = controller.planEditor(SMapPlanEditorOptions(
credentials: SMapPlanCredentials.token(() async => (await http.get(Uri.parse('https://my.server/plan-token'))).body),
plan: SMapPlan.fromJson(planJson),
));
SMap(controller: controller, tools: [editing.panelTool()]); // the panel as the map tool "plan-editor"
editing.editor.addListener((e) {
if (e is SMapPlanChange && !e.pending) save(e.plan!.toJson());
});
SMapPlanCredentials.key('smk_…') takes a map key. A stop is dragged after a long press.
React Native
import { ScrollView, View } from 'react-native';
import { SMap, usePlanEditor, PlanEditorLayer, PlanPanel } from '@superrouteca/smap-react-native';
function Dispatch({ plan }) {
return (
<SMap apiKey="smk_…">
<PlanEditing plan={plan} />
</SMap>
);
}
function PlanEditing({ plan }) {
const editor = usePlanEditor({ credentials: { token: () => fetch('https://my.server/plan-token').then((r) => r.json()) }, plan });
useEffect(() => {
if (!editor) return undefined;
const save = (e) => { if (!e.pending) store(e.plan); };
editor.on('change', save);
return () => { editor.off('change', save); };
}, [editor]);
return (
<>
<PlanEditorLayer editor={editor} />
<View style={{ position: 'absolute', top: 8, left: 8, bottom: 40 }}>
<ScrollView><PlanPanel editor={editor} language="fr" /></ScrollView>
</View>
</>
);
}
usePlanEditor makes the editor once the map is ready and destroys it with the component. Put the panel in a
<Tool id="plan-editor"> child of <SMap> to have the tools control show and hide it.
Windows
using Superroute.SMap;
var options = new SMapPlanEditorOptions(SMapPlanCredentials.Token(async () =>
SMapPlanToken.FromJson(await http.GetStringAsync("https://my.server/plan-token"))!))
{
Plan = SMapPlan.FromJson(planJson),
};
var editing = map.PlanEditor(options);
editing.Editor.Changed += (_, e) => { if (!e.Pending && e.Plan is { } plan) Save(plan.ToJson()); };
editing.Editor.Error += (_, e) => Log(e.Exception.Code, e.Exception.Reason);
SMapPlanCredentials.Key("smk_…") takes a map key. In Windows Forms the panel lies over the map; in WPF it docks
beside the map. Call the editor on the UI thread; its events are raised there.
4. Step 3: events and the plan
The editor reports what happens through its events:
| Event | When | Fields |
|---|---|---|
change |
After every change of what is shown | kind, plan, result, answer, pending, canUndo, canRedo |
busy |
When calls start and when they all have ended | busy |
error |
An edit or a call was refused | error (status, code, message, reason), kind |
snapshot |
The distance snapshot was opened, used, expired, unavailable or mismatched | status, matrix |
preview |
A dragged stop's target was priced, or the preview was cleared | routes, result, or null |
destroy |
The editor was destroyed |
kindis the edit that changed the plan:set,recost,resequence,move,remove,insert,refine,undo,redo,lock,choicesorerror.planis the plan to keep:vehicles,jobs,routes(one route per vehicle, in driving order),unassigned(every job on no route), andregion,profileanddeparted_atwhen given. Send it back withsetPlan()or asplanthe next time the editor is made.pendingis true while the plan shown is not priced yet. Save the plan whenpendingis false.resultholds the pricedroutesandsummaryof the plan shown;answerthe whole answer of the last call.
editor.on('change', (e) => {
if (!e.pending) save(e.plan);
});
editor.on('error', (e) => showMessage(e.error.code, e.error.reason));
editor.on('busy', (e) => spinner(e.busy));
state() gives everything at once: plan, result, busy, error, canUndo, canRedo, choices, locked,
last and preview. getPlan() and getResult() give the plan and its prices.
The same edits can be made from code. On the web:
| Call | What it does |
|---|---|
setPlan(plan) |
Replaces the plan; the history starts again and the plan is priced |
resequence(vehicleId, stops) |
Gives a route a new order |
move(stops, vehicleId, position?) |
Moves stops to a route at a position from 1, counted without them; none: last |
remove(stops) |
Takes stops off their routes |
insert(jobs, { limit, objective, nearest }?) |
Places new jobs, or unassigned jobs by id; choices() and choose(job, i) |
refine({ vehicleIds, freeze }?) |
Re-orders routes around the locked stops (lock, unlock) |
undo() / redo() |
Moves through the edits |
preview(routes) / clearPreview() |
Prices other routes without changing the plan |
destroy() |
Cancels the calls, closes the snapshot, removes the layers and the panel |
Each call returns a promise of its outcome. A call that a newer edit replaced gives null; a refused call is rejected
with the error. The native SDKs have the same calls under their own names (Kotlin suspend functions, Swift async
methods, Dart futures, C# …Async tasks).
5. Step 4: errors
The editor asks your server for a new token by itself after 401 SESSION_EXPIRED, and repeats the call once. Every
other refusal is reported as an error event with its code and reason, the edit is taken back, and the call is not
repeated:
| Status | code |
reason |
Meaning | What to do |
|---|---|---|---|---|
| 401 | SESSION_EXPIRED |
expired |
The token's life ended | Nothing: the editor fetches a new one |
| 401 | SESSION_REVOKED |
key_revoked |
The API key that issued the token was revoked or has expired | Issue tokens with a valid key |
| 401 | SESSION_REVOKED |
entitlements_changed |
The account's capabilities changed since the token was issued | Issue a new token |
| 401 | TOKEN_INVALID |
The token is damaged, or the map key is unknown | Check what the token function returns, or the key | |
| 403 | NOT_AUTHORIZED |
origin_not_allowed |
The page or app is not in the token's origins or on the key's allowlist |
Add it there |
| 403 | NOT_AUTHORIZED |
no_plan_capability |
The API key holds no plan capability | Grant a plan capability to the key |
| 403 | NOT_AUTHORIZED |
plan_editing_off |
Allow plan editing is off for the map key | Switch it on |
| 403 | NOT_AUTHORIZED |
key_off |
The map key is switched off | Switch it on, or use another key |
| 403 | NOT_AUTHORIZED |
plan_routes_only |
The credential was used for a call other than the plan calls | Use it for the plan calls only |
| 403 | NOT_AUTHORIZED |
api_token_required |
POST /v1/plan/token was called without the API token |
Call it from your server with the API token |
| 403 | CAPABILITY_NOT_ENABLED, CAPABILITY_NOT_GRANTED_TO_KEY |
The account or the API key does not hold the capability of the call | Ask Superroute to enable it, or grant it to the key | |
| 422 | VALIDATION_FAILED |
The plan is not valid, for example a vehicle or job without id |
Correct the plan; details names the field |
|
| 422 | PLAN_REFINE_REFUSED |
no_snapshot, too_large, stops_left_out, failed |
The routes cannot be refined in one call | Refine fewer routes, or use POST /v1/routing/refine |
| 402 | QUOTA_EXHAUSTED |
The included volume is used up and the account's policy is to block | See Billing and quotas | |
| 429 | RATE_LIMITED, CONCURRENCY_LIMIT |
Too many calls at once or in a short time | Wait for Retry-After |
Violations of the plan's rules (time windows, capacity, skills and the vehicles' limits) are not errors: they are
reported on the stops and routes and marked in the panel. A snapshot never makes a call fail: when one cannot be
opened, the editor calls without it and reports snapshot with failed.
6. Billing
The editor's calls are the plan calls and are billed as such: per node, the stops of the edited plan that were
priced, under the capability of each call (plan.recost, plan.resequence, plan.move, plan.remove,
plan.insert, plan.refine). A preview is a plan.recost call. Opening and closing distance snapshots and issuing
plan editor tokens are free. Calls made with a token or a map key are billed, limited and recorded as calls of the
API key that issued the token, or of the account for a map key.
7. Try it
The playground runs the editor on a map. Without a
credential it calls a stand-in of the plan calls in the page; with a plan editor token whose origins name this site,
or none, it calls the Open Platform.