Skip to main content
Often something will happen in Axle’s system that you want to know about in your own system. For example, a user might finish onboarding and become eligible for dispatch. You can receive these events in real time via webhooks. Messages are delivered at-least-once, and may arrive out of order, so your handler should be idempotent and sequence on created_at if order matters.

Registering your endpoint

We send you messages at a URL you provide, with one URL per environment, and a signing secret which you can use to verify that the message came from Axle. To register your endpoint you can get in touch and provide the URL and secret for the events you want to receive. If you want different events to go to different endpoints we can do that too. We’ll first implement the webhook in the sandbox environment and once you’ve signed the integration off we’ll move it to production. You can change the URL or secret at any time, and we’ll start sending events to the new URL, and rotate the secret for you. Your endpoint only needs to accept HTTP POST requests with a JSON body, and return a 2xx response once the event is durably accepted.

Events

The vpp: events are for battery fleets on the Axle VPP, and carry a power setpoint in kW. asset:dispatch:requested is for EV charging, and carries a coarse action rather than a setpoint. An integration normally receives one family or the other, not both.

The envelope

Every event shares the same envelope, with the event-specific fields in payload, sent as Content-Type: application/json. We’ve formatted the example below for readability; on the wire it’s compact JSON, which matters once you get to verifying the signature.
Field order isn’t part of the contract, so parse the JSON rather than matching on it. It is stable for a given event, including across retries, but don’t rely on it matching the order documented here.

Event payloads

The payload object contains the fields specific to the event type. The fields are stable for a given event, including across retries, but don’t rely on them matching the order documented here. Field names aren’t shared across event types, so read the table for the event you’re handling rather than assuming a name carries over from another one.

user:onboarding:complete

asset:dispatch:requested

Asks you to drive an asset to a setpoint over a window. The event is sent when the dispatch is scheduled, which can be ahead of from_dt, so don’t act on it until the window opens. Return a 2xx if you will execute the dispatch, and a 4xx if you can’t: the asset is unknown, the window is invalid, or the asset is offline, for example. We may add actions over time, so return a 4xx for any action you don’t recognise.

vpp:asset:registered

Fires when a customer finishes signup on Axle’s hosted VPP flow and we create the battery asset. It carries the identifiers you need for telemetry and dispatch, along with your own device serial so you can match the asset to your customer. Return a 2xx once you’ve recorded the asset, and a 4xx only for a genuine refusal, such as a device serial you don’t recognise.
See Axle VPP integration for where this sits in the wider signup flow.

vpp:dispatch:requested

Asks you to hold a target power at each battery’s inverter over a window. One event covers every asset affected by a single grid event, so you get one delivery per event rather than one per battery. We give you at least 30 minutes’ notice before an event, normally 4 hours to a day ahead. The event is sent when the dispatch is scheduled, so don’t act on it until start_time.
This event is batched, so a 4xx refuses the whole instruction and we won’t retry any part of it. If you don’t recognise one asset_id, skip that entry and still return a 2xx. Return a 5xx only if you can’t accept the instruction at all.
Deduplicate on the envelope id as you would for any other event. Because the instruction is batched, that covers every asset in the event in one go.
power_kw is the target at the inverter. We normally dispatch at the inverter’s maximum power rating, and account for household load potentially limiting the power the battery delivers.
We may add fields to a payload without changing version, so ignore anything you don’t recognise rather than rejecting the request. We’ll version the envelope before we make a breaking change.

Verifying the signature

Every request carries two headers: Verify in four steps; both samples below do all of them.
1

Take the raw request body

Verify against the exact bytes we sent. Parsing the JSON and re-serialising it will change the signature.
2

Parse x-axle-sig into its fields

Split on ,, then on the first =. Ignore fields you don’t recognise; we may add a second signature scheme (v2=...) alongside v1 before retiring it.
3

Reject stale timestamps

Discard the request if t is more than 300 seconds from your clock. This is what bounds the replay window.
4

Recompute and compare

Compute the hex HMAC-SHA256 of "{t}.{body}", keyed by your signing secret, and compare it to v1 in constant time. Use the secret exactly as we gave it to you, as a UTF-8 string, without decoding it first.

Test vector

Check your implementation against this before you go live. Note the compact JSON, with no spaces between tokens. Verifying that header against that body at a received_at within 300 seconds of 1786456932 should succeed. Changing a single byte of the body, the secret or the timestamp should make it fail.
If we’re part-way through rotating your secret you may hold two at once. Accept a request that verifies against either, and drop the old one when we confirm the rotation is done.

Responding

Return any 2xx once you’ve durably accepted the event. We time an attempt out after 10 seconds, so persisting the event and processing it afterwards is usually easier than doing the work inline.
A 4xx drops the event for good. If your handler can’t accept it right now, because a dependency is down for example, return a 5xx and we’ll come back to it.

Retries

We retry a failed delivery with exponential backoff: 30 seconds after the first failure, doubling each time, capped at 4 hours, with ±10% jitter. Retries stop 24 hours after the event’s created_at, not 24 hours after the first attempt, so a delivery that starts late has a shorter window. Once that budget is spent we stop, and we won’t replay the event later.

Delivery guarantees

Delivery is at-least-once and unordered, so make your handler idempotent.
  • Duplicates happen. A network failure after your endpoint has committed but before we see the response is indistinguishable from a failure, so we retry. Deduplicate on the envelope id, which is stable across every attempt of the same event, rather than on created_at or payload contents.
  • Order isn’t guaranteed. Retries interleave with fresh events, so a later event can arrive before an earlier one. Where order matters, sequence on created_at and treat an older event as a no-op if you’ve already applied a newer one.
  • Timing is best-effort. Events are normally delivered within seconds of created_at, but there’s no latency guarantee. Don’t gate a user-facing flow on a webhook arriving promptly.

Quick checklist

  • HTTPS endpoint live at the URL you gave us, one per environment.
  • x-axle-sig verified against the raw body before the event is acted on, and the test vector above passing.
  • Timestamps outside the 300-second tolerance rejected.
  • Events deduplicated on the envelope id.
  • Batched events handled per asset, with unrecognised asset_ids skipped rather than refusing the whole instruction.
  • 2xx returned only once the event is durably accepted, 5xx on transient failure, and 4xx only for a genuine refusal.
  • Signing secrets stored as secrets, and kept distinct per environment.
If you hit anything that isn’t covered here, get in touch. We want to make sure this documentation is complete and correct, and that your integration works smoothly.

Next steps

Smart Charging integration

Where onboarding fits in the wider integration

Axle VPP integration

Where the vpp: events fit in a battery fleet integration