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.
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:
200Response
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:
200Response
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:
| Header | Holds |
|---|---|
X-Flowion-Event | The event type, e.g. transaction.ended |
X-Flowion-Delivery | The delivery's sequence, to deduplicate on |
X-Flowion-Timestamp | Unix seconds when this attempt was signed |
X-Flowion-Signature | v1= 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}
return False
= + b +
= +
return
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_digestdoes.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
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:
Each delivery shows its last outcome (lastOutcome), the exact body that was signed and sent (requestBody) and the start of your reply (responseBody):
lastOutcome | Look at |
|---|---|
http_401, http_403 and other 4xx | Usually your signature check, or a route that moved. Compare against requestBody |
http_500 and other 5xx, timeout | Your receiver is down or too slow |
connect_failed | Nothing answered at that address |
blocked_address | The 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
| Field | Type | Meaning |
|---|---|---|
sequence | integer | This 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_id | integer | The underlying event. One event fans out to every matching subscription, so this value is shared between them while `sequence` is not. |
event_type | string | Which event this is — one of the values below, and the same string as the X-Flowion-Event header. |
occurred_at | string (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_id | string (uuid) | The project the event belongs to. A subscription only ever receives events from its own project. |
mode | string · 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_id | string (uuid) · optional | The station involved, when the event has one. Omitted entirely rather than sent as null. |
data | object | The 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. |
| Header | Meaning |
|---|---|
X-Flowion-Event | The event type, mirroring the body's `event_type`. Lets a receiver route without parsing the body. |
X-Flowion-Delivery | The delivery id, mirroring the body's `sequence`. The value to deduplicate on. |
X-Flowion-Timestamp | Unix seconds at the moment this attempt was signed. Part of the signed input, so it cannot be edited in flight. |
X-Flowion-Signature | The 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.
| Field | Type | Meaning |
|---|---|---|
protocol_version | string | The 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.
| Field | Type | Meaning |
|---|---|---|
connector_id | string (uuid) | The connector, as addressed by the REST API. |
status | string | The new state: Available, Occupied, Reserved, Unavailable or Faulted. |
previous_status | string | What 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.
| Field | Type | Meaning |
|---|---|---|
transaction_id | string (uuid) | The transaction, as addressed by the REST API. |
connector_id | string (uuid) | The connector it is running on. |
external_user_id | string · optional | The 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_id | string (uuid) · optional | The 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.
| Field | Type | Meaning |
|---|---|---|
transaction_id | string (uuid) | The transaction, as addressed by the REST API. |
connector_id | string (uuid) | The connector it is running on. |
external_user_id | string · optional | The 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_id | string (uuid) · optional | The 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.
| Field | Type | Meaning |
|---|---|---|
transaction_id | string (uuid) | The transaction, as addressed by the REST API. |
connector_id | string (uuid) | The connector it is running on. |
external_user_id | string · optional | The 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_id | string (uuid) · optional | The 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.
| Field | Type | Meaning |
|---|---|---|
test | boolean | Always true. Present so a receiver can branch on the body alone, without having to trust the event type. |
message | string | Human-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.
| Field | Type | Meaning |
|---|---|---|
diagnosticsRequestId | string (uuid) | The request, as addressed by the REST API. |
chargingStationId | string (uuid) | The station the request was for. |
state | string | The terminal state: uploaded, noDiagnostics, failed or timedOut. |
failureReason | string | Why it failed, or null unless state is failed. |
stationFileName | string · optional | The file name as reported by the station. Present only when state is uploaded. |
fileSize | integer · optional | The file size in bytes. Present only when state is uploaded. |
resolvedAt | string (RFC 3339) | When the request reached this terminal state. |