Log inTalk to us
Getting started

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 runsYour 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 stopsA webhook endpoint that acknowledges quickly
Webhook delivery with retries, and the readable event logYour users, bookings and business rules, in your own database
Tariffs, bills and, optionally, paymentsA 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.

CallWhat to expect
Reads (GET) of stations, sessions, bills, eventsQuick; safe to repeat
Creating a location, station, tariff or external charging session; finalizing a sessionSynchronous 200 or 201; the same key and body replays the result
Charger commands, session start and stop202 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.

sequence diagram; its source follows
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
  1. 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.
  2. Authorize. Your processor authorizes the payment first.
  3. Create. POST …/charging-sessions with station_id, connector_id, tariff_id, application_reference set to the booking ID, and the required processor, merchant_reference and payment_reference (see create and charge).
  4. Start. POST …/charging-sessions/{session_id}/start with {"funding_confirmed": true, "limit": {...}}. Save the operation ID.
  5. Wait. Webhooks move the booking from starting to charging; the scheduled job from rule 3 repairs any gap.
  6. Finalize and capture. When charging_session.status_changed reports awaiting_bill, POST …/charging-sessions/{session_id}/finalize with {"transaction_id": ...} from the session's usage.transaction_id and the key finalize-{booking}. Capture total_minor from the returned bill (or from bill.finalized) in your processor and release the rest of the authorization (external sessions). The scheduled job finalizes any session it finds in awaiting_bill.

The quickstart gets your first authenticated request, and apps shows how an app is registered and installed.