> ## Documentation Index
> Fetch the complete documentation index at: https://docs.axle.energy/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed events from Axle when something happens to one of your users or assets

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.

```mermaid theme={null}
sequenceDiagram
    participant Axle
    participant You as Your endpoint

    Axle->>You: POST one event, signed with x-axle-sig
    You->>You: Verify signature against raw body

    alt Durably accepted
        You-->>Axle: 2xx
        Note over Axle,You: Delivered, nothing more to send
    else Genuine refusal
        You-->>Axle: 4xx
        Note over Axle,You: Dropped for good, never retried
    else Transient failure
        You-->>Axle: 5xx, timeout or connection error
        Axle->>Axle: Wait 30s, doubling to 4h
        Axle->>You: Retry, same id and created_at
        Note over Axle,You: Retries stop 24h after created_at
    end
```

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](mailto:hello@axle.energy) 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

| Event type                 | Fires when                                                                                                     |
| -------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `user:onboarding:complete` | A user finishes onboarding: enrolled, consented, and eligible for dispatch.                                    |
| `asset:dispatch:requested` | A market dispatch is scheduled for one of your assets and we need you to execute it over the window.           |
| `vpp:asset:registered`     | A battery is registered on the Axle VPP through Axle's signup flow, and we hand you the identifiers for it.    |
| `vpp:dispatch:requested`   | A VPP grid event is scheduled and we need you to hold a target power on each affected battery over the window. |

<Note>
  The `vpp:` events are for battery fleets on the [Axle VPP](/workflows/axle-vpp/integration), 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.
</Note>

## 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.

```json theme={null}
{
  "id": "0198f0a1-2b3c-7d4e-8f90-1a2b3c4d5e6f",
  "version": "v0.0.0",
  "event_type": "user:onboarding:complete",
  "created_at": "2026-08-11T14:02:11.482913Z",
  "payload": {
    "site_id": "3c361164-5187-4b4b-8e8a-7b3f67c4d5a8",
    "completed_at": "2026-08-11T14:02:11.301045Z",
    "external_user_id": "1234567"
  }
}
```

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.

| Field        | Type                   | Description                                                                                                                        |
| ------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `id`         | string (UUID)          | Unique identifier for this event. Stable across retries, so use it to deduplicate.                                                 |
| `version`    | string                 | Envelope version, set per event type. The `vpp:` events are at `v1.0.0`; the others stay at `v0.0.0` while their contracts settle. |
| `event_type` | string                 | Which event this is, from the table above.                                                                                         |
| `created_at` | string (ISO 8601, UTC) | When Axle recorded the event, which can be earlier than when we sent it. A retried event keeps its original `created_at`.          |
| `payload`    | object                 | Event-specific fields.                                                                                                             |

## 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`

| Field                      | Type                   | Description                                                              |
| -------------------------- | ---------------------- | ------------------------------------------------------------------------ |
| `payload.site_id`          | string (UUID)          | The Axle site identifier for this user.                                  |
| `payload.external_user_id` | string                 | Your own identifier for the user, as supplied when the site was created. |
| `payload.completed_at`     | string (ISO 8601, UTC) | When the user finished onboarding.                                       |

### `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.

| Field              | Type                   | Description                                                      |
| ------------------ | ---------------------- | ---------------------------------------------------------------- |
| `payload.asset_id` | string (UUID)          | The Axle asset identifier for the asset being dispatched.        |
| `payload.action`   | string                 | What to do with the asset over the window, from the table below. |
| `payload.from_dt`  | string (ISO 8601, UTC) | Start of the dispatch window.                                    |
| `payload.to_dt`    | string (ISO 8601, UTC) | End of the dispatch window.                                      |

| Action              | Meaning                                                                      |
| ------------------- | ---------------------------------------------------------------------------- |
| `pause_consumption` | Drive the asset's consumption to **0%** of its rated power for the window.   |
| `max_consumption`   | Drive the asset's consumption to **100%** of its rated power for the window. |

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](/workflows/axle-vpp/api-reference/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.

| Field                   | Type                   | Description                                                                                             |
| ----------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------- |
| `payload.site_id`       | string (UUID)          | The Axle site identifier for the customer.                                                              |
| `payload.asset_id`      | string (UUID)          | The Axle asset identifier for the battery. Use this for telemetry and dispatch.                         |
| `payload.device_serial` | string                 | The device serial as returned by your own device-list API, so you can match the asset to your customer. |
| `payload.registered_at` | string (ISO 8601, UTC) | When Axle created the asset.                                                                            |

```json theme={null}
{
  "id": "0198f0b2-3c4d-7e5f-8a01-2b3c4d5e6f70",
  "version": "v1.0.0",
  "event_type": "vpp:asset:registered",
  "created_at": "2026-08-11T14:02:11.482913Z",
  "payload": {
    "site_id": "9f2b7a13-4c8d-4e21-9a76-5d1c3b8e0f42",
    "asset_id": "3c361164-5187-4b4b-8e8a-7b3f67c4d5a8",
    "device_serial": "OEM-SERIAL-12345",
    "registered_at": "2026-08-11T14:02:11.301045Z"
  }
}
```

See [Axle VPP integration](/workflows/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`.

| Field                       | Type                   | Description                                                                                                                                        |
| --------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `payload.event_id`          | string (UUID)          | Identifier for the grid event itself, shared by every asset in this instruction. Distinct from the envelope `id`, which identifies this delivery.  |
| `payload.issued_timestamp`  | string (ISO 8601, UTC) | When Axle issued the instruction.                                                                                                                  |
| `payload.start_time`        | string (ISO 8601, UTC) | Start of the dispatch window.                                                                                                                      |
| `payload.end_time`          | string (ISO 8601, UTC) | End of the dispatch window.                                                                                                                        |
| `payload.assets`            | array                  | One entry per affected asset.                                                                                                                      |
| `payload.assets[].asset_id` | string (UUID)          | The Axle asset identifier, as supplied at registration.                                                                                            |
| `payload.assets[].power_kw` | number                 | Target power for the asset across the window, applied at the inverter. Positive = charge (import into the battery), negative = discharge (export). |

```json theme={null}
{
  "id": "0198f0c3-4d5e-7f60-9b12-3c4d5e6f7081",
  "version": "v1.0.0",
  "event_type": "vpp:dispatch:requested",
  "created_at": "2026-06-29T17:02:37.512804Z",
  "payload": {
    "event_id": "eab8c867-7c4d-4116-a587-02c9d1f13a16",
    "issued_timestamp": "2026-06-29T17:02:37.448917+00:00",
    "start_time": "2026-06-30T20:30:00+00:00",
    "end_time": "2026-06-30T21:30:00+00:00",
    "assets": [
      { "asset_id": "3c361164-5187-4b4b-8e8a-7b3f67c4d5a8", "power_kw": -6.915 },
      { "asset_id": "1a161164-5187-4b4b-8e8a-7b3f67c4d5a8", "power_kw": -4.915 }
    ]
  }
}
```

<Warning>
  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.
</Warning>

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.

<Note>
  `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.
</Note>

<Note>
  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.
</Note>

## Verifying the signature

Every request carries two headers:

| Header       | Description                                                                                                                               |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `x-axle-sig` | `t=<unix timestamp>,v1=<hex HMAC-SHA256>`. We cover the timestamp in the signature, so a captured request can't be replayed indefinitely. |
| `x-axle-ref` | The envelope `id`, for correlation in your logs. Not signed, so don't rely on it for anything security-sensitive.                         |

Verify in four steps; both samples below do all of them.

<Steps>
  <Step title="Take the raw request body">
    Verify against the exact bytes we sent. Parsing the JSON and re-serialising it will change the
    signature.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Reject stale timestamps">
    Discard the request if `t` is more than 300 seconds from your clock. This is what bounds the
    replay window.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import hashlib
    import hmac

    TOLERANCE_SEC = 300


    def verify(secret: str, body: str, signature_header: str, received_at_ts: int) -> bool:
        fields = dict(f.split("=", 1) for f in signature_header.split(",") if "=" in f)
        timestamp, signature = fields.get("t"), fields.get("v1")
        # isdigit() alone accepts non-ASCII digits that int() then rejects, so check both
        if not timestamp or not signature or not (timestamp.isascii() and timestamp.isdigit()):
            return False

        if abs(received_at_ts - int(timestamp)) > TOLERANCE_SEC:
            return False

        expected = hmac.new(secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
        # compare as bytes: compare_digest rejects str containing non-ASCII
        return hmac.compare_digest(expected.encode(), signature.encode())
    ```
  </Tab>

  <Tab title="Node.js">
    ```javascript theme={null}
    const crypto = require("crypto");

    const TOLERANCE_SEC = 300;

    function verify(secret, body, signatureHeader, receivedAtTs) {
      const fields = Object.fromEntries(
        signatureHeader.split(",").map((field) => {
          const i = field.indexOf("=");
          return [field.slice(0, i), field.slice(i + 1)];
        }),
      );
      const { t, v1 } = fields;
      if (!/^\d+$/.test(t ?? "") || !v1) return false;

      if (Math.abs(receivedAtTs - Number(t)) > TOLERANCE_SEC) return false;

      const expected = crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
      const a = Buffer.from(expected, "utf8");
      const b = Buffer.from(v1, "utf8");
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }
    ```
  </Tab>
</Tabs>

### Test vector

Check your implementation against this before you go live. Note the compact JSON, with no spaces between tokens.

|              |                                                                                                                                                                                                                                                                                              |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Secret       | `axle_whsec_example`                                                                                                                                                                                                                                                                         |
| Body         | `{"id":"0198f0a1-2b3c-7d4e-8f90-1a2b3c4d5e6f","version":"v0.0.0","event_type":"user:onboarding:complete","created_at":"2026-08-11T14:02:11.482913Z","payload":{"site_id":"3c361164-5187-4b4b-8e8a-7b3f67c4d5a8","completed_at":"2026-08-11T14:02:11.301045Z","external_user_id":"1234567"}}` |
| `x-axle-sig` | `t=1786456932,v1=d4554b1fccf035c008a8040d20ad5a6c8e6f45299371945c7895084bf7300211`                                                                                                                                                                                                           |

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.

<Tip>
  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.
</Tip>

## 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.

| Your response                    | What we do                                                                                                    |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `2xx`                            | Mark the delivery succeeded. Nothing more to do.                                                              |
| `4xx`                            | Treat it as a definitive refusal and stop. A malformed request or the wrong endpoint won't improve with time. |
| `5xx`, timeout, connection error | Try again with backoff. See [Retries](#retries).                                                              |

<Warning>
  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.
</Warning>

## 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

<Note>
  Delivery is **at-least-once and unordered**, so make your handler idempotent.
</Note>

* **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_id`s 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](mailto:hello@axle.energy). We want to make sure this documentation is complete and correct, and that your integration works smoothly.

## Next steps

<Card title="Smart Charging integration" icon="bolt" href="/workflows/smart-charging/integration">
  Where onboarding fits in the wider integration
</Card>

<Card title="Axle VPP integration" icon="battery-full" href="/workflows/axle-vpp/integration">
  Where the `vpp:` events fit in a battery fleet integration
</Card>
