Open Application Platform Docs

The plan editor

sMap

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 *.domain patterns. 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 a 401 SESSION_EXPIRED. The function may return the token text or the whole data object 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
  • kind is the edit that changed the plan: set, recost, resequence, move, remove, insert, refine, undo, redo, lock, choices or error.
  • plan is the plan to keep: vehicles, jobs, routes (one route per vehicle, in driving order), unassigned (every job on no route), and region, profile and departed_at when given. Send it back with setPlan() or as plan the next time the editor is made.
  • pending is true while the plan shown is not priced yet. Save the plan when pending is false.
  • result holds the priced routes and summary of the plan shown; answer the 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.