Building serverless apps on Ivora
Ivora runs the hard, always-on part: it keeps each connected charger's OCPP WebSocket open, holds the session state and retries what can be retried. Your application is a web app that answers requests and receives webhooks. That shape fits a serverless host such as Vercel, Netlify or Cloudflare Workers, or another serverless or edge-function host.
What runs where
| Ivora runs | Your app runs |
|---|---|
| Each connected charger's OCPP WebSocket (OCPP 1.6, 2.0.1 and 2.1) | Request/response handlers that call the /v1 API |
| Session and transaction state, meter values, limits and stops | A webhook endpoint that acknowledges quickly |
| Webhook delivery with retries, and the readable event log | Your users, bookings and business rules, in your own database |
| Tariffs, bills and, optionally, payments | A scheduled job that reconciles |
Because Ivora holds the charger connections and the session state, your app does not need a process that stays alive between requests. Say plainly what it should not do:
- Hold charger connections. A charger connects to Ivora, never to your app. An app has no OCPP endpoint to keep open.
- Poll in tight loops. Subscribe to webhooks
and keep one slow scheduled read for recovery (rule 3). Each app install and
API key has a request budget (rate limits); a
polling loop spends it and gets
429 rate_limited. An app install's budget is described under scopes an app may request. - Run a timer that stops a session at an energy or amount cap. Ask Ivora for a limit (rule 5).
The five rules
1. Webhooks are retried: verify, then deduplicate by event ID
Delivery is at least once and may arrive out of order. Verify the
Ivora-Signature header on the raw body, store the event id (also sent as
Ivora-Event-Id) and ignore one you have seen. Acknowledge with a 2xx before
any slow work: Ivora waits at most 15 seconds for the whole attempt. Details:
delivery contract.
def handle(request): # any serverless runtime
body = request.body # raw bytes, before any JSON parsing
if not verify(SECRET, request.headers["Ivora-Signature"], body):
return 401
event = json.loads(body)
inserted = db.execute(
"INSERT INTO ivora_events (id, type, payload) VALUES (%s, %s, %s) "
"ON CONFLICT (id) DO NOTHING", (event["id"], event["type"], body))
if inserted: # first time only
queue.enqueue(event["id"]) # do the real work elsewhere
return 200
verify is the function in the delivery contract.
The unique key makes the insert the deduplication, so two concurrent deliveries
of one event cannot both run the work.
2. Send an Idempotency-Key on every write
A serverless platform may run your function twice for one request, or time out
and retry it. Every POST carries an Idempotency-Key. Derive it from your own
record, not from the clock, so a second run of the same function sends the same
key and body and Ivora replays the first result instead of starting a second
session. See retries.
curl -X POST "$IVORA_API/v1/tenants/$TENANT/charging-sessions/$SESSION/start" \
-H "Authorization: Bearer $IVORA_KEY" \
-H "Idempotency-Key: start-$BOOKING_ID" \
-H "Content-Type: application/json" \
-d '{"funding_confirmed": true, "limit": {"energy_kwh": "12.5"}}'
Store the key beside the booking before you send. A different body under the same
key answers 409 idempotency_key_reused; the same key while the first call
still runs answers 409 request_in_progress.
3. Reconcile on a schedule
No webhook platform is perfect: your function can be down, deploying or over quota for longer than Ivora retries (8 attempts over about a day and a half). Run a scheduled job, for example Vercel Cron, every few minutes. It reads the tenant event log from the last cursor it stored and feeds each event through the same inbox as rule 1, so an event that also arrived by webhook is a no-op.
def reconcile(): # scheduled, for example every 5 minutes
cursor = db.get("ivora_cursor") or 0
while True:
page = ivora.get(f"/v1/tenants/{TENANT}/events", params={"after": cursor})
for event in page["data"]:
store_once(event) # the insert from rule 1
if page["next_cursor"] == cursor or not page["data"]:
break
cursor = page["next_cursor"]
db.set("ivora_cursor", cursor)
for session in db.sessions_not_final(): # also re-read what you still wait for
fresh = ivora.get(f"/v1/tenants/{TENANT}/charging-sessions/{session.id}")
apply(fresh) # updates the booking from the session
if fresh["status"] == "awaiting_bill":
finalize(session) # the same call as step 5 below
The log carries the same event id as the webhook. Re-reading an open session
with GET …/charging-sessions/{session_id} also covers a status that changed
while no one was listening, and a session the job finds in awaiting_bill is
finalized there, with the same idempotency key as the webhook path. The job is
a safety net, not the main path.
4. Keep state in your own database
You cannot count on a function keeping anything between calls, and several
can run at once. Keep your
bookings, the Ivora IDs they map to (ecs_… session, bill_…, operation IDs),
the idempotency keys, the event inbox and the cursor in a database your app
owns (a hosted SQL database or a key-value store). Do not keep them in
memory, in module variables or on the function's disk. Ivora holds the charging
state, and you can always read it back; it does not hold your bookings, users or
payments. See ownership.
5. Use session limits, not app timers
"Stop at 12.5 kWh" or "stop at $15" needs something watching the meter, which a
function does not give you. Ask Ivora instead: a
session limit set at start is either
energy_kwh, or amount_minor with currency, converted to energy at the
bill's per-kWh rate (session, time and idle fees are not counted against it).
Ivora watches it against the charger's meter values and stops the session
itself, even if your app is asleep or has been deployed over. You learn about
it from bill.limit_reached. A limit is set once, at start, and cannot be
changed later. A charger that reports no meter values during the session is
never stopped by a limit.
There is no time limit. For "stop after 30 minutes", store the stop time with
the booking and let your scheduled job (rule 3) call
POST …/charging-sessions/{session_id}/stop with a key you stored, such as
stop-{booking}, once that time has passed. You can stop the session yourself
at any time.
When charging ends, the session reaches awaiting_bill. Ivora never finalizes
an application-managed session: your app finalizes it with the transaction ID
it observed, and bill.finalized fires only after that.
curl -X POST "$IVORA_API/v1/tenants/$TENANT/charging-sessions/$SESSION/finalize" \
-H "Authorization: Bearer $IVORA_KEY" \
-H "Idempotency-Key: finalize-$BOOKING_ID" \
-H "Content-Type: application/json" \
-d '{"transaction_id": 21}'
transaction_id is the session's usage.transaction_id. The answer is the
final bill: capture its total_minor in your processor.
Timeouts and cold starts
A cold start adds latency to your function, not to the charger. Nothing time-critical depends on your function being warm, because Ivora does the work that has a deadline.
| Call | What to expect |
|---|---|
Reads (GET) of stations, sessions, bills, events | Quick; safe to repeat |
| Creating a location, station, tariff or external charging session; finalizing a session | Synchronous 200 or 201; the same key and body replays the result |
| Charger commands, session start and stop | 202 with an operation. The API answers once the charging core has taken the command; it does not wait for the charger |
Charger displays and
running cost are not enabled in production yet:
their writes return 409 displays_disabled.
For a 202, take the outcome from the operation.completed webhook or poll
GET /v1/tenants/{tenant_id}/operations/{operation_id} every few seconds, with
a bounded number of tries. If an operation is dispatching or unknown, or a
call ends in a timeout, 502 or 503 outcome_unknown, never resend with a
new key: that can start a second session. Replay the same key and body, or read
the operation or session first. See errors and statuses.
Give the function a timeout of about 30 seconds, and do not wait inside one request for a charger. Return a "starting" page and let a webhook or the status page move it on.
Worked example: start a session from a QR scan
The shape of a minimal flow. There is no full app here; each step is a separate short function, and all state lives in your database.
Diagram source
sequenceDiagram
participant D as Driver
participant F as Your functions
participant DB as Your database
participant I as Ivora API
D->>F: Scans QR, opens /charge/{code} on your host
F->>DB: Map code to station and connector, create booking and keys
F->>F: Your processor authorizes the payment
F->>I: POST charging-sessions (key create-{booking})
I-->>F: 201 session and bill IDs
F->>DB: Store the IDs
F->>I: POST …/start with a limit (key start-{booking})
I-->>F: 202 operation
F-->>D: Starting page
I->>F: Webhook charging_session.status_changed (charging)
F->>DB: Insert event once, update booking
I->>F: Webhook charging_session.status_changed (awaiting_bill)
F->>I: POST …/finalize with usage.transaction_id (key finalize-{booking})
I-->>F: Final bill
F->>DB: Mark done, capture total_minor in your processor- Scan. The sticker encodes a URL on your own host, such as
/charge/{code}. Your function maps the code to a station and connector from your own database and creates a booking row with its own idempotency keys. - Authorize. Your processor authorizes the payment first.
- Create.
POST …/charging-sessionswithstation_id,connector_id,tariff_id,application_referenceset to the booking ID, and the requiredprocessor,merchant_referenceandpayment_reference(see create and charge). - Start.
POST …/charging-sessions/{session_id}/startwith{"funding_confirmed": true, "limit": {...}}. Save the operation ID. - Wait. Webhooks move the booking from
startingtocharging; the scheduled job from rule 3 repairs any gap. - Finalize and capture. When
charging_session.status_changedreportsawaiting_bill,POST …/charging-sessions/{session_id}/finalizewith{"transaction_id": ...}from the session'susage.transaction_idand the keyfinalize-{booking}. Capturetotal_minorfrom the returned bill (or frombill.finalized) in your processor and release the rest of the authorization (external sessions). The scheduled job finalizes any session it finds inawaiting_bill.
The quickstart gets your first authenticated request, and apps shows how an app is registered and installed.



