Migration portal: workspaces, parity, batches and rollback
A migration is one move of your project's chargers from a previous platform
to Ivora. Its workspace, at /v1/migrations/{migration_id}, is shared by the
teams that work on the move: your own team, Ivora support, and the partner,
installer or observer teams you invite. It holds the
chargers being moved and their state, the batches that move them, the rollback
jobs, the audit log and the credential of the charger proxy that routes each
charger to one platform or the other.
Rollback is the first thing the workspace does. Any charger, a whole site or every charger of the migration goes back to your previous platform as one job, and the charger proxy applies each rollback within seconds. The per-charger rollback of the pilot charger rollback page keeps working, on the same rollbacks: a rollback asked through either is the same record, and both pages show it.
Before a charger moves, a parity report checks that Ivora has the same configuration, prices and sessions for it as your previous platform. Chargers move to Ivora in batches: you approve a batch, and it moves its chargers a few at a time through the charger proxy, pausing itself when something looks wrong. Until the pilot's charger proxy pulls its work through the runner channel, the Ivora migration team makes the moves on the proxy itself. Either way the proxy reports each move, so the workspace always shows where every charger is routed.
Roles
| Role | Who | May |
|---|---|---|
client | Your project team's owners and admins | Read; import the network and revert an import; plan, approve and run batches; roll back any charger; invite and remove teams; manage runner credentials |
support | Ivora support staff | Read; import the network and revert an import; plan and run batches (never approve them); roll back any charger; manage runner credentials; add chargers to the migration; invite and change teams for you; remove an invited team in an emergency |
partner | An invited partner or installer team's owners, admins, developers and operators | Read; import the network into the migration; plan and run batches (you approve them); roll back the chargers its own team moved, inside the rollback window; read the OCPP log of the migration's chargers |
observer | Your team's developers, operators and viewers; an invited team's viewers; an invited observer team; Ivora platform support | Read only |
A member's role is the participant's kind, capped by their own role in their team:
| Team role | In your team | In an invited team |
|---|---|---|
| owner, admin | client | the team's kind (partner or observer) |
| developer, operator | observer | the team's kind |
| viewer | observer | observer |
| billing | none | none |
- Billing members and other projects' members have no role. For them the
migration does not exist (
404 migration_not_found). permissionsalso listsmigration:import,migration:plan,migration:executeandmigration:approve.migration:importruns the network import into the migration, and lets a team migration key run the project's network import (dry run, apply and reads, not a revert).migration:plan,migration:approveandmigration:executeare the batch steps; only the client approves, and a batch a partner submitted is approved by someone else than its submitter.- Ivora support is on every migration and cannot be removed, with or
without an Ivora rollback commitment (
settings.rollback_sla): Ivora administrators always keep read access and emergency rollback, so Ivora can always roll back for you. Removing it answers409 migration_participant_required. - A personal API key (or an Ivora staff key) bound to the project acts with
its owner's role, capped by its own scopes: reading needs
stations:read; planning a batch needsstations:write; approving and running one, rolling back and runner credentials needstations:admin. Project keys, app tokens and OAuth grants are refused on these routes. The audit log is read by signed-in users only. - A team migration key acts with its creator's role in
their own team, capped by its
migration:*scopes. - Every workspace read returns your
roleandpermissions, so a UI shows only the actions you may take. The API checks again on every request.
Invite teams
Your owners and admins, or Ivora support for you, invite the teams that work
on the migration:
POST /v1/migrations/{migration_id}/participants with an Idempotency-Key
and one of
{"team_id": "team_...", "kind": "partner"}
{"invite_email": "lead@installer.example", "kind": "partner", "scope": {"location_ids": [3]}}
{"invite_email": "pm@consultant.example", "kind": "observer"}
- By team, when the partner already has an Ivora team: one of its owners or admins accepts.
- By email otherwise: the person with that confirmed email accepts, for a team they own or administer, or for a new team they name. A new team has no project, so it has no usage and needs no payment method. It has no CSMS access either, and does not count toward the teams that inherit it.
scope.location_idsnarrows the team to those sites: it sees and acts on the migration's chargers there only, its counts cover them, and its audit log shows its own team's entries.- An invite lapses after 14 days.
Invitees list their invites at GET /v1/me/migration-invites and answer with
POST /v1/me/migration-invites/{id}/accept ({"team_id": "team_..."} or
{"team_name": "Installer Co"}; nothing for an invite to a team) or
.../decline.
GET /v1/migrations/{migration_id}/participants lists the teams, with team
names only, never their members, and who invited each one (invited_by:
client, or support for a team Ivora support invited for you). You and
Ivora support see an email invite's address; other participants do not. PATCH .../participants/{id} changes an invited team's
kind or scope ({"all_sites": true} removes the narrowing), and
DELETE .../participants/{id} removes the team or revokes its invite. Both
take effect on the team's next request. Your own team and Ivora support stay.
Ivora support may invite and change teams for you, for example when your
installer is set up while you are not around, and in an emergency remove an
invited team (or revoke its invite). Every such change is in the audit log
with "by": "support" and actor_team_id null; an invite also has
"invited_by": "support". Only you and Ivora support invite teams or change
them; an invited team never does.
What an invited team sees. This migration and nothing else of your
project. Its members are not members of your team, so every
/v1/tenants/{tenant_id}/... route of your project refuses them
(403). The migration routes return their own projection: each charger's
name, location, state and route, the jobs, the audit log and, for partners,
the redacted OCPP log of the migration's chargers
(GET /v1/migrations/{migration_id}/chargers/{station_id}/ocpp-messages,
redacted as on your own OCPP log). Never your other
stations, drivers, tokens, transactions, bills, keys or webhooks.
A partner's rollback. A partner rolls back the chargers its own team
moved to Ivora (a batch its team started or resumed asked for the move),
while that move is inside the migration's rollback_window_days, and any
charger whose move its team asked for is still out. Other chargers are refused
(403 migration_rollback_not_permitted); with location_ids or all: true
they are left out of the job. You and Ivora support roll back any charger.
Open a workspace
POST /v1/tenants/{tenant_id}/migrations with an Idempotency-Key and
stations:admin (your team's owners and admins, or Ivora staff):
{"name": "EV Connect to Ivora", "source_platform": "evconnect",
"settings": {"rollback_window_days": 7, "drain_per_second": 5, "rollback_sla": true}}
settings.rollback_sla (default true) records an Ivora rollback commitment
(rollback by Ivora within 4 business hours). Leave it on unless you agreed
otherwise with Ivora. With false there is no formal commitment, but Ivora
support stays on the migration either way.
201 returns the workspace with its two participants: client (your team)
and support (Ivora, team_id null). A retry with the same key returns the
same migration. A project can have several migrations, for example one per
region. GET /v1/tenants/{tenant_id}/migrations lists the project's
migrations (stations:read).
GET /v1/me/migrations lists every migration you work on, with your role in
each; Ivora staff see every migration. Each one carries its project's name
(project_name, null when the charging core cannot be read) and its client
team (team_id, team_name). Narrow the list with team_id (the client
team), tenant_id (the project), status and source_platform; filters
combine and never widen what you see. view=summary leaves out
participants and runners (null) for a long list, keeping the charger
counts and runner_healthy; read one migration for the rest.
Request a migration
If your project has no workspace yet, ask Ivora for one:
POST /v1/tenants/{tenant_id}/migration-requests, signed in, by anyone with
access to the project (stations:read; API keys and app tokens cannot).
The migration discovery questionnaire collects the
request's values and everything else the move needs; send its answers to your
Ivora contact with the request:
{"source_platform": "evconnect", "chargers_estimate": 120, "sites_estimate": 8,
"target_from": "2026-12-01", "target_to": "2026-12-15",
"partner": {"name": "Installer Co", "email": "lead@installer.example"},
"notes": "Two sites first"}
source_platformisevconnect,other_ocpp(another OCPP platform) orother. Everything else is optional.201returns the request withstatusopen, and Ivora is alerted. A project has one open request at a time: sending another returns the open one (200). A team has one open request at a time too: a request for another project of your team answers409 migration_request_team_open(details.request_id) until Ivora settles the open one.- Each user sends at most 5 requests a minute and each client address 20
(
429 rate_limited, retry afterRetry-Afterseconds). - A request changes nothing. Ivora opens the workspace (your team is its
client) and sets the request to
workspace_createdwith itsmigration_id, or todeclinedwith adecision_reasonyou see. GET /v1/me/migration-requestslists the requests you sent, newest first;GET /v1/migration-requests/{id}reads one.- Requests are read by you and by Ivora administrators only: Ivora platform
support gets
403onGET /v1/migration-requestsand on another's request. The partner's name and email and your notes are seen by Ivora administrators and you only. Anyone else who sees the request (a colleague whose request returned yours) gets them asnull. The audit entries (migration.request.created,migration.request.workspace_created,migration.request.declined) carry IDs only, never a name or an email, and are in your team's audit log, not the migration's: no other team learns that a request existed.
Pause, complete or archive
PATCH /v1/migrations/{migration_id} changes the workspace's name, its
settings (only the fields you send) and its status, in one call. It needs
migration:manage: your team's owners and admins, or Ivora support on your
behalf. Sending the current value changes nothing.
{"status": "paused", "reason": "Site visit moved to next week"}
status | What it means | It can become |
|---|---|---|
active | Everything runs | paused, completed |
paused | Imports stop, and no batch is planned, started or resumed (409 migration_closed). Its running batches pause with it (paused_reason: migration_paused); resume them once the migration is active again. Batches can still be paused and cancelled | active, completed, archived |
completed | The move is done. The workspace is a read-only record (board, jobs, audit log): imports, batches, added chargers, invites and new runner credentials answer 409 migration_closed. Its chargers stay in it. completed_at says when | active (reopen, with a reason), archived |
archived | Closed for good, and its chargers are released: another migration can add them. Its participants can still read it, its board, charger histories and audit log included | nothing (409 migration_closed) |
- Moving to a status not in the last column is
409 migration_status_invalid, withdetails.allowed. An active migration is paused or completed before it is archived. - Completing or archiving waits until no batch is
approved,runningorpaused(409 migration_batches_open, withdetails.batch_ids: cancel them, or let them finish) and no rollback is still on its way to the charger proxy (409 migration_routes_open, withdetails.count). - Rollbacks work in every status. Ivora can always roll back for you: on a paused, completed or archived migration a rollback is accepted as on an active one (the same roles and rollback window), and the migration's runner credentials keep pulling and reporting routes, so the charger proxy applies it. Nothing else is queued on a completed or archived migration, so its runner idles. Once a migration is completed the previous platform may have dropped the charger: check that it still accepts the charger before you roll back.
- An archived migration's chargers can join another migration (a network
import or Ivora support adding them). That migration takes the charger over,
recorded as
migration.charger.releasedon the archived one, and from then on its rollbacks reach it. Until then, a rollback of the charger still goes through the archived migration's runner. - Each change is in the audit log, in the same transaction:
migration.settings.changed(the old and new values),migration.paused,migration.resumed,migration.completed,migration.reopenedandmigration.archived, with yourreasonand your role (supportwhen Ivora support acted on your behalf), andmigration.batch.pausedfor each batch a pause stopped. GET /v1/me/migrations?status=archivedlists your past migrations.
Read a workspace and its chargers
GET /v1/migrations/{migration_id} returns the settings, the participants,
the number of chargers in each state, the runner credentials and whether a
runner was seen in the last 60 seconds (runner_healthy).
GET /v1/migrations/{migration_id}/chargers is the state board, ordered by
charger_key (page with cursor). Narrow it with state (repeat it for
several states), location_id (one site), job_id (the chargers of a job,
skipped ones included) and batch_id (a batch's chargers):
GET /v1/migrations/{migration_id}/chargers?state=moved&state=rolling_back&location_id=3
state | Meaning |
|---|---|
imported | In the migration; neither the charger proxy nor a site flip has recorded its route yet |
proxied | Routed through the proxy to your previous platform |
shadow | Routed to your previous platform, with shadow mode on: Ivora observes a copy of its traffic and sends nothing |
parity_checked | Routed to your previous platform, and its latest parity report passed; it has not been on Ivora since that report (shadow mode may still be on) |
moved | Routed to Ivora |
rolling_back | A rollback is asked and not finished |
rolled_back | Routed back to your previous platform after it was on Ivora: by a rollback, or by a rollback made on the proxy itself |
Each row also carries state_since (when it entered its state), route (from
the proxy's latest report), route_desired (the last route asked for through
the API; a batch's move shows there only once the proxy applied it),
connected (the side its socket is open on), proxy_stale (no
report for five minutes), its open_request, its last_rollback with the
reconnect status and its latest batch (id, status and the charger's
step there) and its latest parity report (report_id, status and
checked_at; null when it was never checked).
State history. Every change of a charger's state is recorded.
GET /v1/migrations/{migration_id}/chargers/{station_id} returns the board's
row with history, its last 100 changes, newest first:
{"from": "moved", "to": "rolling_back", "cause": "rollback", "actor": "user:...",
"at": "2026-10-28T09:12:03+00:00",
"detail": {"route": "ivora", "rollback_id": "mrb_...", "rollback_status": "pending", "shadow": false,
"parity_passed": false}}
cause is what moved it:
cause | When |
|---|---|
added | It joined the migration (its first state) |
proxy | The charger proxy reported a route or a connection, or stopped reporting it |
shadow | Ivora staff turned shadow mode on or off |
site_flip | A site flip was recorded for a charger the proxy does not report (the proxy's report wins the route otherwise) |
rollback | A rollback was asked, applied, failed, superseded or expired |
parity | A parity report checked it |
reconciled | A change found when the board was read, because the record of its event was missed |
The board follows what Ivora already records: it never routes a charger. Each
change is also in the audit log as
migration.charger.state_changed. Recording a state never blocks the event
behind it (a rollback, a proxy report): a change it misses is recorded on the
next read of the board, with the cause reconciled. A charger in a batch
keeps these states; where it is in its batch is the row's batch.step.
Ivora support adds the chargers from the pilot proxy's allowlist:
POST /v1/migrations/{migration_id}/chargers with each charger's routing key
and its Ivora station (station_id, station_name or charger_id). A station
is in at most one migration that is not archived (409 station_in_other_migration).
An archived migration's chargers are released when another migration adds
them; a charger whose emergency rollback is still on its way to the archived
migration's runner stays there until the runner reports it
(details.reason rollback_pending).
Chargers you import into the migration join it by themselves
(Import the network).
Import the network
The project's network import, run from the migration
by its teams: the same CSV template and OCPI sources, limits, dry run, apply,
resume and move plan, under /v1/migrations/{migration_id}/imports.
| Step | Contract |
|---|---|
| Get the CSV template | GET /v1/migrations/{migration_id}/imports/template |
| Dry run | POST /v1/migrations/{migration_id}/imports with text/csv, or JSON with an OCPI document or feed (201, nothing written) |
| Review | GET …/imports/{import_id} |
| Apply | POST …/imports?apply=true&dry_run_id={import_id} with an Idempotency-Key (200) |
| Move the chargers | GET …/imports/{import_id}/move-plan (JSON, or ?format=csv) |
| Undo | POST …/imports/{import_id}/revert with an Idempotency-Key |
| History | GET …/imports (this migration's imports only) |
Once applied, every station the import names (created, updated or already
identical) joins the migration's chargers in the state imported, with its
first board transition (cause added) and one migration.charger.added
audit entry naming the import (detail.import_id). Its routing key is
<project>/<charger_id>, such as 18/CP001, or the charger's global
connection name where per-project charger URLs are off: put the same key on
the charger proxy's allowlist, because the runner reports chargers by it.
Each station row of the import then carries migration_state and
charger_key. A station already in another migration stays out of this one
(its migration_state is null).
- Who: the
client,supportandpartnerroles (migration:import), over every site; a participant narrowed to some sites, observers and callers with no role cannot (403 forbidden, or404 migration_not_found). A personal or staff key needsstations:write, a team migration keymigration:import; project keys and app tokens get404. Only theclientandsupportrevert (migration:manage). - A signed-in partner imports into the client's project through the
migration alone; it never reaches the project's own routes. A partner
team's migration key with
migration:importcan stillPOST /v1/tenants/{tenant_id}/imports(the migration tool route); that import is a project import (migration_idisnull) and its stations do not join the migration. Use this route to add them to the board. - The migration must be active: a paused, completed or archived one answers
dry runs, applies and reverts with
409 migration_closed(reads stay open). - Another migration's import, or a project import, is
404 import_not_foundhere. The project's import list shows migration imports too, with theirmigration_id; applying one through the project's route also adds its stations to the migration. - The addition and its audit entry are one transaction. When the entry cannot
be stored, the answer is
503 migration_audit_unavailable: the import is applied, but its stations are not in the migration yet. Apply again with the sameIdempotency-Key: it answers200, adds them and recordsnetwork_import.applied; nothing is written to the charging core twice. (A new key answers409 import_appliedand records nonetwork_import.applied.) - A revert deletes what the import created, as on the project's route, and
the deleted stations leave the migration (
migration.charger.released). A later import of the same charger takes its routing key again. - Only the team that made a dry run applies it (signed in, or the key that
made it): the client never applies a partner's dry run, nor a partner the
client's (
403 forbidden). - A key makes at most 10 dry runs and applies a minute (
429 rate_limited). Each dry run, apply and revert is audited asnetwork_import.*with the migration's ID and your team.
Parity reports
A parity report compares each charger in scope with what your previous platform has for it, so you see before the move what would change on Ivora. It checks three sections:
| Section | Previous platform | Ivora |
|---|---|---|
configuration | The keys and values in the charger's baseline | The configuration Ivora recorded for the charger (its configuration reads, accepted changes and OCPP 2.x reports; Ivora never asks the charger for a report) |
tariff | The baseline's currency and prices | The tariff Ivora bills each connector by: its own, else its location's or the project's default |
sessions | The baseline's session count and energy in a window, or, for an OCPP 1.6 charger in shadow mode, the sessions the charger proxy recorded from your previous platform's link | The sessions Ivora recorded on the charger that started in the same window (in shadow mode, its copy) |
1. Record the baseline. From your previous platform's export, each
charger's configuration, tariff and sessions:
PUT /v1/migrations/{migration_id}/parity/baseline (migration:import: the
client, support and partner roles; a key needs stations:write).
{"chargers": [{"station_id": 12,
"configuration": {"MeterValueSampleInterval": "60", "LocalAuthorizeOffline": "true"},
"tariff": {"currency": "USD", "rate_minor_per_kwh": 35, "session_fee_minor": 100},
"sessions": {"since": "2026-10-19T00:00:00+00:00", "until": "2026-10-26T00:00:00+00:00",
"count": 42, "energy_wh": 512340}}]}
- 64 KiB per call, as every
/v1body (413 request_too_largeabove it): about 25 chargers with a full OCPP 1.6 configuration; tariff-only baselines up to 500. A configuration has at most 300 keys, each value at most 500 characters. Split a larger export into several calls. A section you send replaces that section of the charger's baseline; the others stay. - Name configuration keys as Ivora's configuration read does: OCPP 1.6 keys,
or
Component/Variableon OCPP 2.x. Secret keys (passwords, keys, tokens) and the keys that decide where the charger connects (CentralSystemURLand similar: never compared, and a URL can carry credentials) are dropped and never stored; the answer lists them indropped_keys. - Prices are minor units of the currency (35 is $0.35), decimals for a
sub-cent price (35.5 is $0.355, at most 4 decimals);
nulland0both mean no such price. Energy is Wh. The sessions window is at most 31 days.
2. Run a report. POST /v1/migrations/{migration_id}/parity/reports
with an Idempotency-Key (migration:plan: client, support and
partner), with one scope as for a rollback (station_ids, location_ids
or all: true; 500 chargers at most):
{"all": true, "ignore_keys": ["HeartbeatInterval"],
"tolerances": {"session_count": 0, "energy_wh": 100, "energy_ratio": 0.01},
"require": ["configuration", "tariff", "sessions"]}
201 returns the report with each charger's result. Each section is
passed, failed or not_checked (with a reason):
- configuration compares the keys of the baseline. Values are compared
as text, booleans in any case; a key Ivora has not recorded is
missing. Keys that decide where the charger connects (its URL, identity, security profile) differ on purpose and areskipped, with secrets and the keys inignore_keys. On OCPP 2.x only theActualattribute is compared; a label Ivora recorded with different values on several EVSEs or attributes is skipped and listed inambiguous.not_recorded: Ivora has not read the charger's configuration yet (a charger in shadow mode answers its previous platform only).too_many_items: Ivora recorded more than 2,000 items for the charger, so the section is not checked rather than reporting keys as missing. - tariff compares the currency and each price with every connector's
tariff on Ivora, exactly (sub-cent prices too); a connector with no tariff
fails. When a connector's tariff is complex (elements, billing rules or a
time zone), its base prices are not what Ivora bills, so the section is
not_checked(complex_tariff) unless a connector has no tariff or another currency, which still fails. - sessions compares counts within
session_countand energy withinenergy_whorenergy_ratioof your previous platform's total, whichever is larger. Without baseline sessions, an OCPP 1.6 charger in shadow mode is compared with the proxy's record of your previous platform's sessions that started between the report'ssinceanduntil(default: the last 7 days); otherwise the section isnot_checked(no_baseline). A session still running counts on both sides, with the energy recorded so far (none on the proxy's record until it stops): run reports over a window that has ended.
A charger passed when no section failed and every section in require
passed; failed when a section differs; else incomplete. A charger on your
previous platform whose latest report passed is parity_checked on the
board, until it moves or a later report
does not pass. A report reads only: nothing is sent to a charger and nothing
changes in Ivora's records of it. A retry with the same Idempotency-Key
returns the same report.
3. Read and download. GET .../parity/reports lists the reports, newest
first, with their counts; GET .../parity/reports/{report_id} reads one, and
?format=csv downloads a summary with one row per charger. Any participant
reads them; a team narrowed to some sites records, runs and reads the
chargers at its sites only, with counts and scope of those; a report with
none of its chargers is left out of its list and is
404 migration_parity_report_not_found for it. Each baseline and report
is in the audit log, and nothing is recorded when its entry
cannot be stored (503 migration_audit_unavailable). A paused migration
takes baselines and reports; a completed or archived one answers
409 migration_closed, and its reports stay readable.
Batches
A batch is a group of the migration's chargers moved to Ivora together, once you approved it, a few at a time.
Diagram source
stateDiagram-v2
[*] --> draft : plan
draft --> submitted : submit
submitted --> approved : approve (client)
draft --> draft : edit
submitted --> draft : edit
approved --> draft : edit (approval withdrawn)
approved --> running : start
running --> paused : pause (by hand or by itself)
paused --> running : resume
running --> completed : every charger settled
paused --> completed : the last wave settles while paused
draft --> cancelled
submitted --> cancelled
approved --> cancelled
running --> cancelled
paused --> cancelled
approved --> rolled_back : rollback of the batch
running --> rolled_back : rollback of the batch
paused --> rolled_back
completed --> rolled_back
cancelled --> rolled_back| Step | Route | Who |
|---|---|---|
| Plan | POST /v1/migrations/{migration_id}/batches with an Idempotency-Key | migration:plan: client, support, partner |
| Edit | PATCH …/batches/{batch_id} (name, station_ids, pacing) | migration:plan, before it starts |
| Submit | POST …/batches/{batch_id}/submit | migration:plan |
| Approve | POST …/batches/{batch_id}/approve | migration:approve: the client only |
| Start | POST …/batches/{batch_id}/start | migration:execute: client, support, partner |
| Pause, resume | POST …/batches/{batch_id}/pause, …/resume | migration:execute |
| Cancel | POST …/batches/{batch_id}/cancel | migration:plan before approval, migration:execute after |
| Read | GET …/batches (filter status), GET …/batches/{batch_id} | Any participant |
| Roll back | POST …/rollback with batch_id (Roll back) | migration:rollback |
{"name": "Depot, night 1", "station_ids": [12, 13, 14, 15, 16, 17],
"pacing": {"wave_size": 5, "interval_seconds": 60, "canary": 2,
"max_failure_ratio": 0.2, "idle_timeout_minutes": 60,
"move_timeout_minutes": 30}}
- Chargers.
station_ids, orlocation_idsfor every migration charger at those sites; 500 at most. A charger is in one open batch at a time (409 migration_charger_in_batch). A participant narrowed to some sites plans their chargers only, sees a batch's chargers at its sites and acts only on batches whose chargers are all there. - Approval. Nothing moves before the client approves. The approval binds
the batch's chargers and pacing (
approval.digest): any edit returns the batch todraftand the client approves again. A batch a partner submitted is approved by someone else than its submitter (403 migration_four_eyes). - Start. Only an approved batch starts, and only when a runner of the
migration was seen in the last 60 seconds
(
409 migration_runner_unhealthy). The moves are asked in the name of whoever started or last resumed the batch, and of their team. - Every step is audited in the same transaction (
migration.batch.*); when the entry cannot be stored the step is not taken (503 migration_audit_unavailable). Repeating a step on a batch already there changes nothing; another status answers409 migration_batch_state.
Pacing. A running batch moves wave_size chargers at a time (1-50). Each
wave's chargers get a route request of kind: move, desired: ivora and
require_idle: true, which the charger proxy's runner pulls and applies under
its own rules: it moves a charger only when it is idle and its own checks pass.
The next wave starts interval_seconds after the last one settled (every
charger moved, refused or failed).
- Canary. The first wave has
canarychargers (default 2); then the batch pauses itself (paused_reason: canary) until someone looks and resumes it.canary: 0skips it. - Failures. After a wave, when more than
max_failure_ratioof the chargers tried so far failed, the batch pauses itself (failure_ratio). - Busy chargers. A charger the proxy refused because it is charging goes
back in the queue and is tried again in a later wave, 5 minutes later at
the earliest, until
idle_timeout_minutesafter its first try. Then it isblocked(not_idle): never forced. Put it in a new batch later. - No answer. A move the proxy has not answered
move_timeout_minutesafter it was sent (default 30) expires: it counts as a failed charger of its wave (failed,detail: expired) and raises an alert for Ivora on-call, so a lost request never keeps a batch running. When the proxy reports it applied after all, the charger counts as moved (detail: late). - A rollback anywhere in the migration pauses every running batch
(
rollback_requested): a rollback is never paced behind a move. - The migration paused pauses every running batch (
migration_paused), and no wave starts while the migration is not active; moves already out still settle. See Pause, complete or archive. - No runner. A running batch with no runner seen for a minute pauses on
its next read (
runner_unhealthy). - Pause stops new waves. Moves no runner has received yet are withdrawn
(their requests are
supersededwithdetail: paused): those chargers wait again and are sent again on resume. A move a runner already received finishes and is recorded. The automatic pauses (rollback_requested,runner_unhealthy,migration_paused) withdraw the same way. Pausing and cancelling a batch work whatever the migration's status; planning, editing, submitting, approving, starting and resuming need an active migration. - Cancel stops the batch for good: waiting chargers and moves no runner
has received yet are skipped (
detail: cancelled), a move a runner received finishes, and moved chargers stay on Ivora. Cancelling never rolls back; roll the batch back for that.
The batch advances while its runner polls, reports and sends heartbeats; with no runner nothing moves.
Each charger's step in GET …/batches/{batch_id}:
step | Meaning |
|---|---|
pending | Waiting for its wave (detail: busy: refused while charging, tried again from retry_at) |
moving | Its move request is out |
moved | On Ivora (detail: already_on_ivora when it was before its wave, late when the proxy answered after its move expired); move_s is the seconds from the request to the proxy's report |
blocked | Not moved: not_idle, not_proxied (the proxy does not report it), shadow (shadow mode is on) or rollback_pending |
failed | The proxy failed it (failed), its checks refused it (refused_parity) or it did not answer within move_timeout_minutes (expired) |
superseded | A rollback took over its move |
skipped | The batch was cancelled or rolled back before its wave, or the charger left the migration (detail) |
A batch also shows its waves, next_wave_at, started_at, finished_at,
duration_s, its moves' job (job_id, kind: move at
GET …/jobs/{job_id}, one request per try) and rollbacks: each rollback
job of its chargers with when it was asked, how many chargers the proxy
switched back (applied), the last switch (last_applied_at) and
switch_s, the seconds from the request to that switch.
A move the API asked for and the proxy applied records the charger's route
for QR sticker scans and one
migration.charger.moved entry (detail.requested: true). The proxy still
records each site flip through
its route once a site's chargers moved.
Batches use the proxy route switch only: a charger moves when the proxy routes it to Ivora, and goes back when the proxy routes it back. Moving chargers that connect to Ivora directly (a reconfiguration of the charger's URL) is not part of batches yet.
Roll back
POST /v1/migrations/{migration_id}/rollback with an Idempotency-Key
(client or support). Give exactly one scope and a reason:
{"station_ids": [12, 13], "reason": "Failed starts at the depot"}
{"location_ids": [3], "reason": "Site 3 goes back while the lot is repaved"}
{"batch_id": "bat_...", "reason": "Night 1 goes back"}
{"all": true, "reason": "Go-live postponed"}
202 returns the job. Every charger in scope that is routed to Ivora, or
still connected there, or whose batch move is still out, gets a rollback; the
others are listed with outcome: skipped and a detail:
already_rolled_back: it is routed to your previous platform already;rollback_pending: a rollback of it is still running;not_proxied: the charger proxy does not report it, so Ivora support rolls it back by hand.
When nothing in scope can be rolled back, the answer is
409 migration_nothing_to_roll_back and nothing is recorded.
Every rollback pauses the migration's running batches. A rollback with
batch_id covers the whole batch: it is refused (403 forbidden) when some of
the batch's chargers are outside your sites, and the batch is not found
(404 migration_batch_not_found) when none are at them. When the job takes
back every charger the batch moved or is moving (one already going back
counts), the batch stops for good (rolled_back; its waiting chargers are
skipped). A partner's job takes back its own team's moves only: when the batch
also holds moves by others, it stays paused (rollback_requested) for the
client or Ivora support to roll back the rest, resume or cancel. Its timings
show on the batch. A move still out is
superseded: the proxy gets the rollback after it, so the charger ends on your
previous platform either way, whichever of the two the proxy reports first. A
move Ivora asked for never cancels a rollback; only a move to Ivora made by
hand on the proxy (or a site flip) after the rollback was asked supersedes it.
A rollback is immediate and needs no idle charger: one running a session goes back too, and that session is then reconciled by hand. Nothing is sent to a charger and nothing changes in Ivora's record of the stations; each charger reconnects to your previous platform by itself, usually within seconds.
Pacing. Up to 50 chargers go at once. Above 50, the proxy closes their
connections at the migration's drain_per_second (default 5 a second), so
your previous platform is not hit by every charger reconnecting at the same
moment. 500 chargers at 5 a second take about 100 seconds plus the reconnects.
GET /v1/migrations/{migration_id}/jobs/{job_id} follows it (and a batch's
moves, kind: move). The job is
running while a charger is still open, then succeeded, or failed when a
charger's rollback failed or expired. Each charger shows its request, the
proxy's outcome, the rollback's rollback_status and its reconnect
status. Each rollback is also readable at
GET /v1/tenants/{tenant_id}/migration/rollbacks/{rollback_id}, and
migration.charger_rolled_back is sent to your webhooks as each one is
applied (pilot charger rollback).
Audit log
GET /v1/migrations/{migration_id}/audit lists the migration's entries and
nothing else, newest first, to every participant (signed in). Filters:
action (exact, or a family such as migration.runner), actor, since,
until. Each entry has migration_id and actor_team_id, the actor's own
team (null for Ivora staff and the proxy).
| Action | When |
|---|---|
migration.created | The workspace was opened |
migration.charger.added | Ivora support added chargers, or an applied network import did (detail.import_id) |
migration.charger.released | A reverted network import deleted chargers, which left the migration |
network_import.dry_run, network_import.applied, network_import.reverted | A network import into the migration (with the key and its creator when a key ran it) |
migration.participant.invited, .accepted, .declined, .changed, .removed | A team was invited, joined, declined, was changed or removed (kind, team and location IDs; never an email) |
migration.rollback.requested | A rollback job was asked for (scope, count, reason; batch_id for a batch) |
migration.parity.baseline_recorded | A baseline was recorded (count, station IDs, sections) |
migration.parity.checked | A parity report was run (report_id, the counts, the failed station IDs) |
migration.batch.created, .edited, .submitted, .approved, .started, .resumed, .cancelled | A batch step (detail.batch_id; an edit says whether it withdrew an approval) |
migration.batch.wave_started | A wave's move requests went out (wave number, chargers), in the name of whoever started or resumed the batch |
migration.batch.paused, migration.batch.completed | Paused by hand, or by itself (detail.automatic, detail.reason); every charger settled |
migration.rollback_requested, migration.rollback_applied, ... | Each charger's rollback, as on the pilot charger rollback page |
migration.charger.moved | The proxy reported a move to Ivora: one the API did not ask for, or a batch's move it applied (detail.requested: true) |
migration.charger.state_changed | Chargers changed state on the board (the cause and each charger's from and to, at most 100 per entry; every change is in the charger's history) |
migration.breakglass.reported | The proxy reported a rollback made on the proxy itself |
migration.runner.created, migration.runner.revoked | A runner credential |
migration.key.created, migration.key.revoked | A team migration key (label, scopes and the key's team; never the secret) |
Workspace changes are recorded in the same transaction as the change: when the
entry cannot be stored, the change is not made and the answer is
503 migration_audit_unavailable (retry with the same Idempotency-Key).
Rollback is the one exception: it is never refused because of its record. A
missing entry raises an alert for Ivora on-call, and the charger proxy's own
audit log keeps the second record.
Team migration keys
For the Ivora CLI or a partner's own integration, a participant team's owners and admins make keys of the migration:
POST /v1/migrations/{migration_id}/keys
{"label": "Installer CLI", "scopes": ["migration:read", "migration:rollback"]}
scopes are migration permissions, each one your role holds (a partner never
holds migration:approve or migration:manage). The answer carries the key
once. At most 20 active keys per team and migration (409 migration_key_limit).
Signed-in users only: an API key cannot make, list or revoke keys
(403 migration_key_not_permitted, also for members who are not owners or
admins and for Ivora support, which uses staff keys).
GET /v1/migrations/{migration_id}/keyslists them, never the secrets: every team's for the client and Ivora support, your own team's otherwise.DELETE /v1/migrations/{migration_id}/keys/{key_id}revokes one at once: your own team's, or any for the client and Ivora support.- The key works on this migration's routes and
GET /v1/accessonly, and, holdingmigration:import, on the migration project's network import (/v1/tenants/{tenant_id}/imports), while the migration is active and its creator's role covers every site. Every other project route refuses it (403 migration_key_refused). On every request it holds what its creator's current role still allows, so a downgrade takes effect at once. Removing the team revokes its keys.
GET /v1/access tells a client (such as ivora login) which migration the key
belongs to, the role and the scopes it holds now; see
Authentication.
The charger proxy's runner channel
The charger proxy pulls what to do from Ivora; Ivora never connects to the proxy. Its sync agent (the runner) uses a runner credential.
Credential. POST /v1/migrations/{migration_id}/runners
{"label": "example-cpo-site-1"} (client or support) returns credential
once; only a digest is stored. It has one scope, migration:routes, for this
migration only. Every other route refuses it (401), and another migration's
runner routes refuse it (403). Keep it on the proxy host only.
DELETE /v1/migrations/{migration_id}/runners/{runner_id} revokes it at once.
Routes. GET /v1/migrations/{migration_id}/routes?after_revision=N&wait=25
answers as soon as there is a route request newer than N (or after wait
seconds, at most 25). Each request has a revision, the charger_key, the
desired route and its kind: rollback (require_idle: false, applied at
once) or a batch's move (desired: ivora, require_idle: true:
the proxy moves the charger only when it is idle and its own checks pass). Pass the reply's
revision as the next after_revision. With after_revision=0 the reply also
lists every charger's desired route. A batch's move shows there only once the
proxy reported it applied, so a runner that syncs to this list after a restart
never moves a charger without require_idle; a move no runner has received
yet can still be withdrawn by a pause or a cancel. A paced request gets its revision when
its time comes, so the cursor never skips one.
limit (1 to 500, default 500) caps the requests in one reply; open rollbacks
come first. The reply's revision covers only requests handed out: every open
request at or below it is in this reply or an earlier one. Ivora counts a move
as received by a runner only at or below the last revision it was given, so
a move a capped page left out can still be withdrawn by a pause. A rollback
above that revision can come again in the next reply: apply each request
once, by its id.
Reports. POST /v1/migrations/{migration_id}/routes/reports takes up to 500
reports, each with the charger's current route and connected sides, and,
for a request, its request_id and outcome:
| Proxy exit | outcome |
|---|---|
| 0, 6 | applied (with connected listing ivora when a socket stayed open: exit 4) |
| 2 | refused_busy (a move: the charger is charging; its batch tries again later) |
| 7 | refused_parity |
| 3, 5 | failed |
| (not applied: moved to Ivora by hand after the request) | superseded |
A rollback applied with a socket to Ivora still open is handed out once more
so the proxy closes it, and completes on the first report without that socket.
A report without request_id records the charger's state; a change made on the
proxy itself is recorded as such and QR sticker scans follow it.
Heartbeat. POST /v1/migrations/{migration_id}/runners/heartbeat every 15
seconds, with the proxy's nodes, upstreams, and routed and connected counts.
The workspace shows the runner healthy while one arrived in the last 60
seconds.
Until the pilot's sync unit is switched to this channel, the proxy keeps using the routes of the pilot charger rollback page, with a proxy key. Rollbacks asked here reach it there too, paced the same way.
Proxy keys
Until the pilot's sync unit moves to the runner channel, the charger proxy and
the migration tools that run beside it report to their project with a proxy
key: a key owned by the project, bound to it, holding migration:proxy alone.
No staff key is needed on the proxy host. The proxy and those tools are Ivora's
migration tooling, maintained apart from the API in Ivora's ivora-migrate
repository; they use only the routes below.
| Route | What the proxy does |
|---|---|
PUT /v1/admin/tenants/{tenant_id}/migration/proxy-state | Reports the chargers it routes; the answer lists the rollbacks to apply |
POST /v1/admin/tenants/{tenant_id}/migration/rollbacks/{rollback_id}/result | Reports a rollback's result |
POST /v1/tenants/{tenant_id}/migration/site-flips | Records a site flip once the site's chargers moved (Idempotency-Key); see below |
PUT /v1/tenants/{tenant_id}/qr/charger-routes | Reports one charger's route after a flip or rollback, so QR sticker scans follow it |
GET /v1/tenants/{tenant_id}/qr/charger-routes | Reads the routes QR scans follow, to compare them with its own |
PUT /v1/tenants/{tenant_id}/stations/{station_id}/shadow/transactions/{control_transaction_id} | Records an OCPP 1.6 shadow session's transaction ID pair |
GET /v1/tenants/{tenant_id}/stations/{station_id}/shadow/transactions | Looks up a pair it lost (control_transaction_id=) |
GET /v1/tenants/{tenant_id}/charger-urls | Reads whether the project uses per-project charger URLs |
- Only a signed-in Ivora administrator makes one:
POST /v1/admin/tenants/{tenant_id}/proxy-keys{"label": "example-cpo-pilot-1"}returns thekeyonce. No team role can grantmigration:proxy, so a project's own keys never hold it. - It works on these routes of its own project only. Another project's routes
answer
403 forbidden, whether that project exists or not; every other route,GET /v1/accessincluded, answers403 restricted_key_refused. The proxy uses that answer to check its own keys: it sends nothing with a keyGET /v1/accessaccepts (a staff key, or any other key). PUT /v1/admin/tenants/{tenant_id}/charger-routes(the staff form of the QR route report) also still takes it, for proxies set up before the tenant-scoped route.- It is listed with the project's keys (
GET /v1/tenants/{tenant_id}/api-keys) and revoked there (DELETE /v1/tenants/{tenant_id}/api-keys/{key_id}) by Ivora administrators or the team's owners and admins only; developers, who revoke the project's other keys, get403 forbiddenfor it. Every revoke is audited asproject.api_key.revokedwith the actor, inGET /v1/tenants/{tenant_id}/audit. Revoking it stops the proxy's reports at once; routing on the proxy itself is unaffected.
Rotation. Make the new key, put it in the proxy host's environment file
under the same variable names (IVORA_PORTAL_KEY for the sync unit,
IVORA_QR_ROUTES_KEY for QR route reports, IVORA_FLIP_NOTIFY_KEY for site
flips, the P1 proxy's shadow.api_key_env for shadow transaction IDs), restart
those units or run one round by hand and check it reports, then revoke the old
key. Replacing an older staff key (stations:write, bound to the project)
works the same way: the API still accepts one on these routes until it is
revoked, but the proxy itself now refuses to report with it, so put the proxy
key in first.
Migration tool routes
Each is the tenant-scoped form of a staff route, with the same body and answer; the staff routes stay for Ivora staff.
| Tenant-scoped route | Staff route | Who may call it |
|---|---|---|
POST /v1/tenants/{tenant_id}/migration/site-flips | POST /v1/admin/tenants/{tenant_id}/site-flips | The proxy key or Ivora platform administrators |
PUT /v1/tenants/{tenant_id}/qr/charger-routes | PUT /v1/admin/tenants/{tenant_id}/charger-routes | The proxy key or Ivora platform administrators |
GET /v1/tenants/{tenant_id}/qr/charger-routes | (none) | stations:read, or the proxy key |
PUT /v1/tenants/{tenant_id}/stations/{station_id}/shadow/transactions/{control_transaction_id} | PUT /v1/admin/stations/{station_id}/shadow/transactions/{control_transaction_id} | The proxy key or Ivora platform administrators |
GET /v1/tenants/{tenant_id}/stations/{station_id}/shadow/transactions | GET /v1/admin/stations/{station_id}/shadow/transactions | stations:read, or the proxy key |
GET /v1/tenants/{tenant_id}/charger-urls | GET /v1/admin/tenants/{tenant_id}/charger-urls | stations:read, or the proxy key |
- Writes stay with the proxy. The project's team roles and its other keys
read these, but never record a flip, a route or a transaction ID pair
(
403 forbidden): the project learns of a flip throughsite.flippedandsite.flip_rolled_back, and of a route through the QR route list. - Isolation. A station or location of another project answers
404, as an unknown one does (station_not_found,location_not_found). - Site flips. The same flip as the staff route: it emits
site.flipped(directionto_ivora) orsite.flip_rolled_backto the project's webhooks with the location, its stations and their EVSEs, and records each moved station's route for QR scans. A retry with the sameIdempotency-Keyreturns the first flip and emits nothing. A flip to Ivora while a station is in shadow mode answers409 station_in_shadow. - QR routes. A report older than the recorded route changes nothing
(
applied: false).ivorais refused while the station is in shadow mode. - Shadow transaction IDs. Meter registers are Wh, exactly as the charger
sent them. A pair is fixed once known; a different value answers
409 shadow_mapping_conflict. An OCPP 2.x station needs no pair (409 shadow_mapping_not_needed); a station not in shadow answers409 station_not_shadow. - Charger URLs. Read-only:
enabled,source(defaultor Ivora'soverride) andconnection_base_url. Changing them stays with Ivora staff. - Audit. Each change is in the project's audit log (
GET /v1/tenants/{tenant_id}/audit):migration.site_flipped(the flip and its event),qr.charger_route(a route that changed) andshadow.transaction_recorded(a pair recorded or completed), with the key as the actor.
Ivora's host monitoring reads the decommissioned-stations and display-adapters
lists with a similar restricted key: a staff fleet:read key (POST /v1/api-keys
{"label": "prod-watch", "scopes": ["fleet:read"]}, by Ivora staff, support
included). It holds fleet:read alone, is bound to no project, reads
GET /v1/admin/decommissioned-stations and GET /v1/admin/display-adapters
only (403 restricted_key_refused elsewhere) and stops when its creator loses their staff role. See
Fleet administration.
Errors
| Code | Status | Meaning |
|---|---|---|
migration_not_found | 404 | No migration has this ID, or you have no role in it |
migration_team_required | 409 | The project has no team yet |
migration_audit_unavailable | 503 | The change was not made: its audit entry could not be stored |
station_in_other_migration | 409 | The station is in another migration that is not archived, or an archived one's rollback is still pending (details.reason rollback_pending) |
migration_charger_conflict | 409 | The routing key or station is already in this migration with another one |
migration_charger_not_in_migration | 404 | These stations are not in this migration, or not at your sites |
migration_nothing_to_roll_back | 409 | No charger in that scope can be rolled back now |
migration_parity_report_not_found | 404 | No parity report of this migration has this ID |
migration_batch_not_found | 404 | No batch of this migration has this ID, or none of its chargers is at your sites |
migration_batch_state | 409 | The batch's status (details.status) does not allow this step |
migration_charger_in_batch | 409 | These chargers are in another open batch (details.batch_id) |
migration_runner_unhealthy | 409 | No runner was seen in the last 60 seconds, so no charger moves |
migration_four_eyes | 403 | A batch a partner submitted is approved by someone else than its submitter |
migration_job_not_found | 404 | No job of this migration has this ID |
migration_runner_not_found | 404 | No runner credential of this migration has this ID |
migration_closed | 409 | The migration's status refuses this: a paused one takes no imports and starts or resumes no batch; a completed or archived one is read-only. Never a rollback |
migration_status_invalid | 409 | The migration cannot move to that status (details.allowed) |
migration_batches_open | 409 | Batches are approved, running or paused (details.batch_ids); cancel them or let them finish before completing or archiving |
migration_routes_open | 409 | Rollbacks are still on their way to the charger proxy; complete or archive once they are settled |
migration_participant_not_found | 404 | No participant or open invite of this migration has this ID |
migration_participant_exists | 409 | The team is already a participant or invited, or the email has an open invite |
migration_participant_fixed | 409 | Your team and Ivora support keep their kind and see every site |
migration_participant_required | 409 | Your team and Ivora support always stay (details.kind) |
migration_team_not_found | 404 | No team has this ID |
migration_invite_not_found | 404 | No migration invite to you has this ID |
migration_invite_closed | 409 | The invite was already answered or revoked |
migration_invite_expired | 410 | The invite lapsed after 14 days |
migration_team_role_required | 403 | Only an owner or admin of the joining team accepts for it |
migration_rollback_not_permitted | 403 | A partner rolls back only the chargers its own team moved, inside the window |
migration_key_refused | 403 | A migration key works on its own migration's routes, GET /v1/access and, holding migration:import, its project's network import only |
migration_key_not_permitted | 403 | Keys are made, listed and revoked by a signed-in owner or admin of a participant team |
migration_key_not_found | 404 | No key of this migration has this ID |
migration_key_limit | 409 | The team holds 20 active keys on this migration |
migration_request_not_found | 404 | No migration request of yours has this ID |
migration_request_team_open | 409 | Your team already has an open request for another project (details.request_id) |
All codes: Errors and statuses.



