Receive signed webhooks

Get a signed POST to your own URL the moment a session starts or ends, or a charger connects or changes state.

3 min readEdit this page
On this page

Register a URL and Flowion POSTs a signed JSON body to it as things happen: a session starts or ends, a charger connects or goes offline, an outlet changes state. No polling, and every delivery is signed so you can prove it came from Flowion.

Add an endpoint

In the console, open a project, then Settings → Webhooks → Add endpoint. Give a URL and pick the events you want. The console creates the signing secret in your browser and shows it once.

Over the API you choose the secret yourself, at least 32 characters:

POST/v1/projects/$PROJECT/webhooksbash
curl -X POST "$API/projects/$PROJECT/webhooks" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"targetUrl": "https://api.example.com/flowion/webhooks",
       "eventTypes": ["transaction.started", "transaction.ended"],
       "signingSecret": "'"$(openssl rand -base64 32)"'"}'
200Response
{
  "id": "0c9b7f2e-6a41-4d8e-9b35-1f7a2c4e8d60",
  "projectId": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
  "targetUrl": "https://api.example.com/flowion/webhooks",
  "eventTypes": ["transaction.started", "transaction.ended"],
  "mode": "live",
  "state": "active",
  "createdAt": "2026-10-01T09:30:00Z",
  "updatedAt": "2026-10-01T09:30:00Z"
}

Adding, testing and changing endpoints needs the webhook.subscription.manage permission; listing them and their deliveries needs webhook.subscription.read.

An endpoint receives one mode's events. Registered with a sandbox API key, it gets sandbox events; with a live key, live events. mode says which.

The URL must be https and resolve to a public address. Flowion checks that on every delivery, not only when you register, so a hostname that later points inside your network shows up as blocked_address in the delivery history.

Send a test

In the console, choose Send a test on the endpoint. Over the API:

POST/v1/projects/$PROJECT/webhooks/$WEBHOOK/testbash
curl -X POST "$API/projects/$PROJECT/webhooks/$WEBHOOK/test" \
  -H "Authorization: Bearer $TOKEN"
200Response
{
  "sequence": 41802
}

A real delivery follows within moments — signed the same way, with the same headers and retries — as the event type webhook.test. Use it to check your signature verification before you wait for a real charge. webhook.test never fires your real handlers, and you cannot subscribe to it.

Verify the signature

Every delivery carries these headers:

HeaderHolds
X-Flowion-EventThe event type, e.g. transaction.ended
X-Flowion-DeliveryThe delivery's sequence, to deduplicate on
X-Flowion-TimestampUnix seconds when this attempt was signed
X-Flowion-Signaturev1= followed by the hex HMAC-SHA256

The signature is HMAC-SHA256, keyed with your signing secret, over the timestamp, a full stop and the raw request body:

{X-Flowion-Timestamp}.{raw request body}
python
import hashlib, hmac, time

def verify(secret: str, timestamp: str, body: bytes,
           signature: str, tolerance: int = 300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + body
    expected = "v1=" + hmac.new(
        secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
  • Hash the bytes you received, before parsing the JSON. Parsing and re-serialising can reorder fields and break a valid signature.

  • Compare in constant time, as compare_digest does.

  • Reject old timestamps. The timestamp is signed, so checking it is what stops someone replaying a captured request. The five minutes above is an example; pick what suits you.

Answer fast, and expect repeats

  • Reply with any 2xx within 10 seconds, then do the work. Anything else, a timeout included, is a failed attempt.

  • Deduplicate on sequence. Delivery is at least once, so the same event can arrive twice.

  • Order by occurred_at, not by arrival.

A failed delivery is retried, up to 16 attempts over 72 hours. Each retry is signed afresh, with a new timestamp.

When an endpoint runs out of retries, it moves to failing and Flowion stops sending to it. Fix the receiver, then:

  • Reactivate the endpoint to resume new deliveries: POST /projects/{projectId}/webhooks/{subscriptionId}/reactivate.

  • Replay each delivery that gave up (abandoned) to send it once more: POST /projects/{projectId}/webhooks/{subscriptionId}/deliveries/{sequence}/replay.

Both are buttons on the endpoint in the console too.

Rotate the secret

POST/v1/projects/$PROJECT/webhooks/$WEBHOOK/rotate-secretbash
curl -X POST "$API/projects/$PROJECT/webhooks/$WEBHOOK/rotate-secret" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"signingSecret": "'"$NEW_SECRET"'"}'

The new secret signs every delivery from the moment the call succeeds; the old one signs nothing more.

When nothing arrives

Open the endpoint's Recent deliveries in the console, or:

GET/v1/projects/$PROJECT/webhooks/$WEBHOOK/deliveriesbash
curl "$API/projects/$PROJECT/webhooks/$WEBHOOK/deliveries" \
  -H "Authorization: Bearer $TOKEN"

Each delivery shows its last outcome (lastOutcome), the exact body that was signed and sent (requestBody) and the start of your reply (responseBody):

lastOutcomeLook at
http_401, http_403 and other 4xxUsually your signature check, or a route that moved. Compare against requestBody
http_500 and other 5xx, timeoutYour receiver is down or too slow
connect_failedNothing answered at that address
blocked_addressThe URL resolves to a private or local address

An empty list means no event you subscribed to has happened in this project yet: a delivery is created when a matching event occurs. Check the events you picked. Event types marked Coming soon below are on their way.

Event reference

Every delivery carries
FieldTypeMeaning
sequenceintegerThis delivery's id. Monotonic per subscription and never reused across retries, but not gapless — use it to order and to deduplicate, not to detect a gap.
event_idintegerThe underlying event. One event fans out to every matching subscription, so this value is shared between them while `sequence` is not.
event_typestringWhich event this is — one of the values below, and the same string as the X-Flowion-Event header.
occurred_atstring (RFC 3339)When the thing happened, not when it was delivered. Unchanged across retries, which is what lets you order events that arrive out of order.
project_idstring (uuid)The project the event belongs to. A subscription only ever receives events from its own project.
modestring · optional"live" or "sandbox" (spec 105) — lets a receiver with a single endpoint tell test traffic from real traffic without a second endpoint to compare against. Omitted, not null, for a delivery whose event predates this field; that is a migration-window fact and will stop happening once every producer states one.
charging_station_idstring (uuid) · optionalThe station involved, when the event has one. Omitted entirely rather than sent as null.
dataobjectThe event-specific body. Its fields are listed per event type below, and carry domain identifiers only — never OCPP wire types (ADR-0004). Refetch over REST for detail.
Headers
HeaderMeaning
X-Flowion-EventThe event type, mirroring the body's `event_type`. Lets a receiver route without parsing the body.
X-Flowion-DeliveryThe delivery id, mirroring the body's `sequence`. The value to deduplicate on.
X-Flowion-TimestampUnix seconds at the moment this attempt was signed. Part of the signed input, so it cannot be edited in flight.
X-Flowion-SignatureThe HMAC over the raw body — see the signature section.

charging_station.connected

A charging station opened its OCPP connection.

The station's WebSocket reached a gateway. Presence is a hint, not a guarantee: a gateway killed without unwinding can leave a station looking connected for a while.

Sent as it happens.

data
FieldTypeMeaning
protocol_versionstringThe OCPP version the station connected with, e.g. "1.6" or "2.0.1".

charging_station.disconnected

A charging station's OCPP connection went away.

The socket closed. This says nothing about whether the station is powered or charging — only that we are no longer talking to it. Sent only when a connection was actually recorded, so a repeated disconnect of an already-disconnected station is silent.

Sent as it happens.

charging_station.registration_pending

A station booted and is waiting to be accepted.

Its BootNotification was answered `Pending` while the CSMS reads its configuration. The station is connected but not yet in service.

Coming soon.

connector.status_changed

A connector changed availability state.

Available, Occupied, Reserved, Unavailable or Faulted, in domain terms rather than either OCPP version's own spelling. Sent on an actual transition only — a station re-reporting the status it already had is silent, or the useful changes would be buried under a stream of "still Available".

Sent as it happens.

data
FieldTypeMeaning
connector_idstring (uuid)The connector, as addressed by the REST API.
statusstringThe new state: Available, Occupied, Reserved, Unavailable or Faulted.
previous_statusstringWhat it was before, or null for a connector whose status had never been set.

transaction.started

A charging transaction began.

Written in the same database transaction as the transaction record itself, so this event exists if and only if the transaction does.

Sent as it happens.

data
FieldTypeMeaning
transaction_idstring (uuid)The transaction, as addressed by the REST API.
connector_idstring (uuid)The connector it is running on.
external_user_idstring · optionalThe integrator's own opaque label for who holds the token this event concerns, echoed straight from `IdTokenRecord.external_user_id` (never a Flowion-owned user record). Absent when the token is unknown to this project or the integrator never labelled it.
holder_principal_idstring (uuid) · optionalThe permitted principal who held the token at the moment it was authorized for this event -- a snapshot, not a live pointer: a card moved to a different principal later does not rewrite earlier deliveries. Absent when the card had no holder at authorization time, or when the event had no authorizing card at all (a meter-value-driven update).

transaction.updated

A charging transaction progressed.

Any non-terminal transaction event — a state change, a plug event, an authorization. Meter values are deliberately not delivered as webhooks: they are high-frequency, and a durable outbox is the wrong place for them. Read them over REST.

Sent as it happens.

data
FieldTypeMeaning
transaction_idstring (uuid)The transaction, as addressed by the REST API.
connector_idstring (uuid)The connector it is running on.
external_user_idstring · optionalThe integrator's own opaque label for who holds the token this event concerns, echoed straight from `IdTokenRecord.external_user_id` (never a Flowion-owned user record). Absent when the token is unknown to this project or the integrator never labelled it.
holder_principal_idstring (uuid) · optionalThe permitted principal who held the token at the moment it was authorized for this event -- a snapshot, not a live pointer: a card moved to a different principal later does not rewrite earlier deliveries. Absent when the card had no holder at authorization time, or when the event had no authorizing card at all (a meter-value-driven update).

transaction.ended

A charging transaction finished.

The terminal event, and usually the one a billing system is waiting for. Fetch the transaction over REST for its totals.

Sent as it happens.

data
FieldTypeMeaning
transaction_idstring (uuid)The transaction, as addressed by the REST API.
connector_idstring (uuid)The connector it is running on.
external_user_idstring · optionalThe integrator's own opaque label for who holds the token this event concerns, echoed straight from `IdTokenRecord.external_user_id` (never a Flowion-owned user record). Absent when the token is unknown to this project or the integrator never labelled it.
holder_principal_idstring (uuid) · optionalThe permitted principal who held the token at the moment it was authorized for this event -- a snapshot, not a live pointer: a card moved to a different principal later does not rewrite earlier deliveries. Absent when the card had no holder at authorization time, or when the event had no authorizing card at all (a meter-value-driven update).

webhook.test

A test delivery you asked for.

Sent only when someone presses "Send test" in the console or calls the test endpoint, and only to the one subscription they chose. Nothing happened in the project. It is signed and delivered through exactly the same path as a real event — same headers, same signature scheme, same retries — which is what makes it worth anything as a check of your endpoint.

Sent when you ask for a test delivery.

data
FieldTypeMeaning
testbooleanAlways true. Present so a receiver can branch on the body alone, without having to trust the event type.
messagestringHuman-readable, for whoever reads your logs an hour later and needs to know this was not a real event.

charging_station_command.status_changed

A remote command reached a status you asked about.

A command sent to a station — reset, unlock, availability change — moved to a status external callers care about.

Coming soon.

diagnostics_request.completed

A 1.6 diagnostics request reached a terminal state.

Sent exactly once per request (spec 109, FR-021), when it resolves to uploaded, noDiagnostics, failed or timedOut. Never carries a download URL or the file itself — fetching the file stays behind the administrator check (FR-016); use POST …/diagnostics/{id}/download with an admin credential.

Sent as it happens.

data
FieldTypeMeaning
diagnosticsRequestIdstring (uuid)The request, as addressed by the REST API.
chargingStationIdstring (uuid)The station the request was for.
statestringThe terminal state: uploaded, noDiagnostics, failed or timedOut.
failureReasonstringWhy it failed, or null unless state is failed.
stationFileNamestring · optionalThe file name as reported by the station. Present only when state is uploaded.
fileSizeinteger · optionalThe file size in bytes. Present only when state is uploaded.
resolvedAtstring (RFC 3339)When the request reached this terminal state.