Receive events with webhooks
Polling an API to find out whether anything happened is a waste of both our time and yours. Register a webhook and we POST a signed JSON body to your URL instead, within a second or so of the event.
Everything on this page below "The envelope" is generated from
webhook-dispatcher itself — the same code that builds and signs the
bodies you receive. If a field appears here, that is because the service
sends it.
Register one#
In the console, open an environment, then Settings → Webhooks. You give a URL and tick the events you want; we generate the signing secret and show it to you once.
Once means once. No API returns a subscription's signing secret, in any form — the service that holds it encrypts it and is the only process that ever decrypts it, to sign with. There is no "reveal" button anywhere, because there is nothing behind it. If you lose the secret, delete the webhook and register a new one.
Over the API it is one call, and there you supply the secret yourself:
curl -X POST https://api.flowion.io/v1/environments/$ENVIRONMENT/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"target_url": "https://api.example.com/flowion/webhooks",
"event_types": ["transaction.started", "transaction.ended"],
"signing_secret": "'"$(openssl rand -base64 32)"'"}'
signing_secret must be at least 32 characters. Registering requires the
Admin role in the environment's organization; listing webhooks and their
deliveries only requires User.
Check it before you wait for a charge#
Press Send test on the webhook in the console, or call the endpoint:
curl -X POST \
https://api.flowion.io/v1/environments/$ENVIRONMENT/webhooks/$WEBHOOK/test \
-H "Authorization: Bearer $TOKEN"
That queues one real delivery to your URL — signed the same way, with the same headers, through the same retry policy — so it exercises the signature verification you actually have to get right. Its outcome shows up in the webhook's delivery history a moment later.
It arrives as webhook.test and never as a real event type, so it cannot
be mistaken for something that happened: sending a test as
transaction.started would fire your real handler with a transaction that
never existed. webhook.test is not subscribable for the same reason —
asking for it in event_types is a 422, because there is nothing to
subscribe to.
What your endpoint has to be#
- https, always.
- Publicly resolvable. A URL whose host resolves to a private, loopback or link-local address is refused — and re-checked on every attempt, not only when you register. A hostname that resolved publicly last month and points inside your network today starts failing, and shows up as a blocked address rather than an outage.
- Fast. Acknowledge with any 2xx as soon as you have the body, and do the work afterwards. An attempt that times out is an ordinary failure and consumes retry budget.
When nothing arrives#
Open the webhook's Deliveries page in the console. Each attempt records what happened, and the distinctions there are the ones that matter: a 4xx from your service is usually a failed signature check or a route that moved, a 5xx or a timeout is your service being unavailable, and a blocked address is us refusing to connect at all.
An empty list is its own answer: a delivery row is created when a matching event occurs, never in advance, so nothing there means no event you subscribed to has happened in that environment yet. Check which events you ticked — some of the vocabulary is registered but not yet produced, and they are marked as such both in the picker and in the reference below.
The envelope
Every delivery is a JSON object with the same outer shape. Only data differs between event types.
| Field | Type | Always present | Meaning |
|---|---|---|---|
sequence | integer | yes | 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 | yes | 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 | yes | Which event this is — one of the values below, and the same string as the X-Flowion-Event header. |
occurred_at | string (RFC 3339) | yes | When the thing happened, not when it was delivered. Unchanged across retries, which is what lets you order events that arrive out of order. |
environment_id | string (uuid) | yes | The environment the event belongs to. A subscription only ever receives events from its own environment. |
charging_station_id | string (uuid) | no | The station involved, when the event has one. Omitted entirely rather than sent as null. |
data | object | yes | 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. |
Headers
| 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. |
Verifying a delivery
HMAC-SHA256 over {X-Flowion-Timestamp}.{raw request body}, hex-encoded, sent as X-Flowion-Signature: v1=<hex digest>.
- Compute the HMAC over the raw bytes you received, before any JSON parse or re-serialization. Re-serializing can reorder fields and will invalidate an otherwise valid signature.
- Compare digests in constant time.
- Reject a delivery whose X-Flowion-Timestamp is far from your own clock. That check is what bounds replay, and it is only worth anything because the timestamp is inside the signed input.
- The secret is the one shown to you once when the subscription was registered. It is stored encrypted and is never returned by any API — if you lose it, register the subscription again.
Retries and ordering
Any 2xx response. Anything else — including a timeout — is a failed attempt and consumes retry budget.
- Up to 16 attempts, over at most 72 hours, with exponential backoff.
- Each attempt is given 10 seconds to complete.
- Delivery is at-least-once. The same event can arrive more than once — deduplicate on `sequence`.
- Order is not guaranteed. Use `occurred_at` to order, not arrival time.
- Respond quickly and do the work afterwards. A slow receiver is indistinguishable from a broken one, and an attempt that times out counts against the retry budget.
- A subscription whose retries are exhausted moves to the `failing` state and stops being delivered to until an operator intervenes.
- Targets must be https and must not resolve to a private, loopback or link-local address — re-checked on every attempt, not only at registration.
Event types
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.
| Field | Type | Always present | Meaning |
|---|---|---|---|
protocol_version | string | yes | The OCPP version the station connected with, e.g. "1.6" or "2.0.1". |
{
"charging_station_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"data": {
"protocol_version": "1.6"
},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "charging_station.connected",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}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.
data is empty for this event.
{
"charging_station_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"data": {},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "charging_station.disconnected",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}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.
Not sent yet. Nothing writes this event in the current build. Its producer was the station-presence write path, which moved into services/asset when transaction and asset state were split into their own databases; the capture step did not move with it. Subscribing is accepted and harmless, and will begin delivering when a producer lands.
data is empty for this event.
{
"charging_station_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"data": {},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "charging_station.registration_pending",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}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".
| Field | Type | Always present | Meaning |
|---|---|---|---|
connector_id | string (uuid) | yes | The connector, as addressed by the REST API. |
status | string | yes | The new state: Available, Occupied, Reserved, Unavailable or Faulted. |
previous_status | string | yes | What it was before, or null for a connector whose status had never been set. |
{
"charging_station_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"data": {
"connector_id": "d1e2f3a4-5555-4666-8777-888899990000",
"previous_status": "Available",
"status": "Occupied"
},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "connector.status_changed",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}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.
| Field | Type | Always present | Meaning |
|---|---|---|---|
transaction_id | string (uuid) | yes | The transaction, as addressed by the REST API. |
connector_id | string (uuid) | yes | The connector it is running on. |
{
"charging_station_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"data": {
"connector_id": "d1e2f3a4-5555-4666-8777-888899990000",
"transaction_id": "c0ffee00-1111-4222-8333-444455556666"
},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "transaction.started",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}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.
| Field | Type | Always present | Meaning |
|---|---|---|---|
transaction_id | string (uuid) | yes | The transaction, as addressed by the REST API. |
connector_id | string (uuid) | yes | The connector it is running on. |
{
"charging_station_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"data": {
"connector_id": "d1e2f3a4-5555-4666-8777-888899990000",
"transaction_id": "c0ffee00-1111-4222-8333-444455556666"
},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "transaction.updated",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}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.
| Field | Type | Always present | Meaning |
|---|---|---|---|
transaction_id | string (uuid) | yes | The transaction, as addressed by the REST API. |
connector_id | string (uuid) | yes | The connector it is running on. |
{
"charging_station_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"data": {
"connector_id": "d1e2f3a4-5555-4666-8777-888899990000",
"transaction_id": "c0ffee00-1111-4222-8333-444455556666"
},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "transaction.ended",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}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 environment. 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.
| Field | Type | Always present | Meaning |
|---|---|---|---|
test | boolean | yes | Always true. Present so a receiver can branch on the body alone, without having to trust the event type. |
message | string | yes | Human-readable, for whoever reads your logs an hour later and needs to know this was not a real event. |
{
"data": {
"message": "This is a test delivery from Flowion. Nothing happened in your environment — someone asked us to send this so they could check this endpoint and its signature verification.",
"test": true
},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "webhook.test",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}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.
Not sent yet. The transition is captured in services/ocpp's own database, which is separate from the one fan-out reads, and the step that carries it across has not been built. Subscribing is accepted and harmless, and will begin delivering when that step lands.
data is empty for this event.
{
"charging_station_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"data": {},
"environment_id": "6f1d6b2c-6f0e-4a5a-9a9e-2b3c4d5e6f70",
"event_id": 9153,
"event_type": "charging_station_command.status_changed",
"occurred_at": "2026-08-30T09:41:04Z",
"sequence": 41802
}