Log inTalk to us
Charger management

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

RoleWhoMay
clientYour project team's owners and adminsRead; import the network and revert an import; plan, approve and run batches; roll back any charger; invite and remove teams; manage runner credentials
supportIvora support staffRead; 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
partnerAn invited partner or installer team's owners, admins, developers and operatorsRead; 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
observerYour team's developers, operators and viewers; an invited team's viewers; an invited observer team; Ivora platform supportRead only

A member's role is the participant's kind, capped by their own role in their team:

Team roleIn your teamIn an invited team
owner, adminclientthe team's kind (partner or observer)
developer, operatorobserverthe team's kind
viewerobserverobserver
billingnonenone
  • Billing members and other projects' members have no role. For them the migration does not exist (404 migration_not_found).
  • permissions also lists migration:import, migration:plan, migration:execute and migration:approve. migration:import runs 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:approve and migration:execute are 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 answers 409 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 needs stations:write; approving and running one, rolling back and runner credentials need stations: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 role and permissions, 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_ids narrows 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_platform is evconnect, other_ocpp (another OCPP platform) or other. Everything else is optional.
  • 201 returns the request with status open, 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 answers 409 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 after Retry-After seconds).
  • A request changes nothing. Ivora opens the workspace (your team is its client) and sets the request to workspace_created with its migration_id, or to declined with a decision_reason you see.
  • GET /v1/me/migration-requests lists 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 403 on GET /v1/migration-requests and 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 as null. 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"}
statusWhat it meansIt can become
activeEverything runspaused, completed
pausedImports 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 cancelledactive, completed, archived
completedThe 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 whenactive (reopen, with a reason), archived
archivedClosed 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 includednothing (409 migration_closed)
  • Moving to a status not in the last column is 409 migration_status_invalid, with details.allowed. An active migration is paused or completed before it is archived.
  • Completing or archiving waits until no batch is approved, running or paused (409 migration_batches_open, with details.batch_ids: cancel them, or let them finish) and no rollback is still on its way to the charger proxy (409 migration_routes_open, with details.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.released on 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.reopened and migration.archived, with your reason and your role (support when Ivora support acted on your behalf), and migration.batch.paused for each batch a pause stopped.
  • GET /v1/me/migrations?status=archived lists 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
stateMeaning
importedIn the migration; neither the charger proxy nor a site flip has recorded its route yet
proxiedRouted through the proxy to your previous platform
shadowRouted to your previous platform, with shadow mode on: Ivora observes a copy of its traffic and sends nothing
parity_checkedRouted 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)
movedRouted to Ivora
rolling_backA rollback is asked and not finished
rolled_backRouted 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:

causeWhen
addedIt joined the migration (its first state)
proxyThe charger proxy reported a route or a connection, or stopped reporting it
shadowIvora staff turned shadow mode on or off
site_flipA site flip was recorded for a charger the proxy does not report (the proxy's report wins the route otherwise)
rollbackA rollback was asked, applied, failed, superseded or expired
parityA parity report checked it
reconciledA 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.

StepContract
Get the CSV templateGET /v1/migrations/{migration_id}/imports/template
Dry runPOST /v1/migrations/{migration_id}/imports with text/csv, or JSON with an OCPI document or feed (201, nothing written)
ReviewGET …/imports/{import_id}
ApplyPOST …/imports?apply=true&dry_run_id={import_id} with an Idempotency-Key (200)
Move the chargersGET …/imports/{import_id}/move-plan (JSON, or ?format=csv)
UndoPOST …/imports/{import_id}/revert with an Idempotency-Key
HistoryGET …/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, support and partner roles (migration:import), over every site; a participant narrowed to some sites, observers and callers with no role cannot (403 forbidden, or 404 migration_not_found). A personal or staff key needs stations:write, a team migration key migration:import; project keys and app tokens get 404. Only the client and support revert (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:import can still POST /v1/tenants/{tenant_id}/imports (the migration tool route); that import is a project import (migration_id is null) 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_found here. The project's import list shows migration imports too, with their migration_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 same Idempotency-Key: it answers 200, adds them and records network_import.applied; nothing is written to the charging core twice. (A new key answers 409 import_applied and records no network_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 as network_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:

SectionPrevious platformIvora
configurationThe keys and values in the charger's baselineThe configuration Ivora recorded for the charger (its configuration reads, accepted changes and OCPP 2.x reports; Ivora never asks the charger for a report)
tariffThe baseline's currency and pricesThe tariff Ivora bills each connector by: its own, else its location's or the project's default
sessionsThe 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 linkThe 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 /v1 body (413 request_too_large above 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/Variable on OCPP 2.x. Secret keys (passwords, keys, tokens) and the keys that decide where the charger connects (CentralSystemURL and similar: never compared, and a URL can carry credentials) are dropped and never stored; the answer lists them in dropped_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); null and 0 both 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 are skipped, with secrets and the keys in ignore_keys. On OCPP 2.x only the Actual attribute is compared; a label Ivora recorded with different values on several EVSEs or attributes is skipped and listed in ambiguous. 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_count and energy within energy_wh or energy_ratio of 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's since and until (default: the last 7 days); otherwise the section is not_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.

stateDiagram-v2 diagram; its source follows
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
StepRouteWho
PlanPOST /v1/migrations/{migration_id}/batches with an Idempotency-Keymigration:plan: client, support, partner
EditPATCH …/batches/{batch_id} (name, station_ids, pacing)migration:plan, before it starts
SubmitPOST …/batches/{batch_id}/submitmigration:plan
ApprovePOST …/batches/{batch_id}/approvemigration:approve: the client only
StartPOST …/batches/{batch_id}/startmigration:execute: client, support, partner
Pause, resumePOST …/batches/{batch_id}/pause, …/resumemigration:execute
CancelPOST …/batches/{batch_id}/cancelmigration:plan before approval, migration:execute after
ReadGET …/batches (filter status), GET …/batches/{batch_id}Any participant
Roll backPOST …/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, or location_ids for 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 to draft and 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 answers 409 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 canary chargers (default 2); then the batch pauses itself (paused_reason: canary) until someone looks and resumes it. canary: 0 skips it.
  • Failures. After a wave, when more than max_failure_ratio of 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_minutes after its first try. Then it is blocked (not_idle): never forced. Put it in a new batch later.
  • No answer. A move the proxy has not answered move_timeout_minutes after 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 superseded with detail: 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}:

stepMeaning
pendingWaiting for its wave (detail: busy: refused while charging, tried again from retry_at)
movingIts move request is out
movedOn 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
blockedNot moved: not_idle, not_proxied (the proxy does not report it), shadow (shadow mode is on) or rollback_pending
failedThe proxy failed it (failed), its checks refused it (refused_parity) or it did not answer within move_timeout_minutes (expired)
supersededA rollback took over its move
skippedThe 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).

ActionWhen
migration.createdThe workspace was opened
migration.charger.addedIvora support added chargers, or an applied network import did (detail.import_id)
migration.charger.releasedA reverted network import deleted chargers, which left the migration
network_import.dry_run, network_import.applied, network_import.revertedA network import into the migration (with the key and its creator when a key ran it)
migration.participant.invited, .accepted, .declined, .changed, .removedA team was invited, joined, declined, was changed or removed (kind, team and location IDs; never an email)
migration.rollback.requestedA rollback job was asked for (scope, count, reason; batch_id for a batch)
migration.parity.baseline_recordedA baseline was recorded (count, station IDs, sections)
migration.parity.checkedA parity report was run (report_id, the counts, the failed station IDs)
migration.batch.created, .edited, .submitted, .approved, .started, .resumed, .cancelledA batch step (detail.batch_id; an edit says whether it withdrew an approval)
migration.batch.wave_startedA wave's move requests went out (wave number, chargers), in the name of whoever started or resumed the batch
migration.batch.paused, migration.batch.completedPaused 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.movedThe 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_changedChargers 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.reportedThe proxy reported a rollback made on the proxy itself
migration.runner.created, migration.runner.revokedA runner credential
migration.key.created, migration.key.revokedA 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}/keys lists 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/access only, and, holding migration: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 exitoutcome
0, 6applied (with connected listing ivora when a socket stayed open: exit 4)
2refused_busy (a move: the charger is charging; its batch tries again later)
7refused_parity
3, 5failed
(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.

RouteWhat the proxy does
PUT /v1/admin/tenants/{tenant_id}/migration/proxy-stateReports the chargers it routes; the answer lists the rollbacks to apply
POST /v1/admin/tenants/{tenant_id}/migration/rollbacks/{rollback_id}/resultReports a rollback's result
POST /v1/tenants/{tenant_id}/migration/site-flipsRecords a site flip once the site's chargers moved (Idempotency-Key); see below
PUT /v1/tenants/{tenant_id}/qr/charger-routesReports one charger's route after a flip or rollback, so QR sticker scans follow it
GET /v1/tenants/{tenant_id}/qr/charger-routesReads 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/transactionsLooks up a pair it lost (control_transaction_id=)
GET /v1/tenants/{tenant_id}/charger-urlsReads 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 the key once. No team role can grant migration: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/access included, answers 403 restricted_key_refused. The proxy uses that answer to check its own keys: it sends nothing with a key GET /v1/access accepts (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, get 403 forbidden for it. Every revoke is audited as project.api_key.revoked with the actor, in GET /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 routeStaff routeWho may call it
POST /v1/tenants/{tenant_id}/migration/site-flipsPOST /v1/admin/tenants/{tenant_id}/site-flipsThe proxy key or Ivora platform administrators
PUT /v1/tenants/{tenant_id}/qr/charger-routesPUT /v1/admin/tenants/{tenant_id}/charger-routesThe 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/transactionsGET /v1/admin/stations/{station_id}/shadow/transactionsstations:read, or the proxy key
GET /v1/tenants/{tenant_id}/charger-urlsGET /v1/admin/tenants/{tenant_id}/charger-urlsstations: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 through site.flipped and site.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 (direction to_ivora) or site.flip_rolled_back to 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 same Idempotency-Key returns the first flip and emits nothing. A flip to Ivora while a station is in shadow mode answers 409 station_in_shadow.
  • QR routes. A report older than the recorded route changes nothing (applied: false). ivora is 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 answers 409 station_not_shadow.
  • Charger URLs. Read-only: enabled, source (default or Ivora's override) and connection_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) and shadow.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

CodeStatusMeaning
migration_not_found404No migration has this ID, or you have no role in it
migration_team_required409The project has no team yet
migration_audit_unavailable503The change was not made: its audit entry could not be stored
station_in_other_migration409The station is in another migration that is not archived, or an archived one's rollback is still pending (details.reason rollback_pending)
migration_charger_conflict409The routing key or station is already in this migration with another one
migration_charger_not_in_migration404These stations are not in this migration, or not at your sites
migration_nothing_to_roll_back409No charger in that scope can be rolled back now
migration_parity_report_not_found404No parity report of this migration has this ID
migration_batch_not_found404No batch of this migration has this ID, or none of its chargers is at your sites
migration_batch_state409The batch's status (details.status) does not allow this step
migration_charger_in_batch409These chargers are in another open batch (details.batch_id)
migration_runner_unhealthy409No runner was seen in the last 60 seconds, so no charger moves
migration_four_eyes403A batch a partner submitted is approved by someone else than its submitter
migration_job_not_found404No job of this migration has this ID
migration_runner_not_found404No runner credential of this migration has this ID
migration_closed409The 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_invalid409The migration cannot move to that status (details.allowed)
migration_batches_open409Batches are approved, running or paused (details.batch_ids); cancel them or let them finish before completing or archiving
migration_routes_open409Rollbacks are still on their way to the charger proxy; complete or archive once they are settled
migration_participant_not_found404No participant or open invite of this migration has this ID
migration_participant_exists409The team is already a participant or invited, or the email has an open invite
migration_participant_fixed409Your team and Ivora support keep their kind and see every site
migration_participant_required409Your team and Ivora support always stay (details.kind)
migration_team_not_found404No team has this ID
migration_invite_not_found404No migration invite to you has this ID
migration_invite_closed409The invite was already answered or revoked
migration_invite_expired410The invite lapsed after 14 days
migration_team_role_required403Only an owner or admin of the joining team accepts for it
migration_rollback_not_permitted403A partner rolls back only the chargers its own team moved, inside the window
migration_key_refused403A 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_permitted403Keys are made, listed and revoked by a signed-in owner or admin of a participant team
migration_key_not_found404No key of this migration has this ID
migration_key_limit409The team holds 20 active keys on this migration
migration_request_not_found404No migration request of yours has this ID
migration_request_team_open409Your team already has an open request for another project (details.request_id)

All codes: Errors and statuses.