# Guide to Axilio Webhooks: Features and Best Practices

[Axilio](https://axilio.ai) runs automations on real phones: you define a workflow, Axilio gives it a device and runs it, and you can also open live sessions on a phone or rent one outright. Its webhooks tell your server when any of that changes state. A run finishes or fails, a session ends, a captured file lands in your library, your balance runs low, or a rented phone drops offline. They replace polling `GET /runs/{run_id}` in a loop.

This guide walks through Axilio's event envelope and its 28 event types, registering a destination, checking signatures, and the parts of Axilio's delivery model you need to design around.

## What are Axilio webhooks?

In Axilio, a webhook endpoint is called a destination. You register it in the dashboard, pick the event types it should receive, and get a signing secret for that destination alone. Every delivery is a `POST` with a JSON envelope signed per Standard Webhooks.

Axilio keeps payloads deliberately thin. They carry IDs, statuses, reasons and timestamps, but never your content: no run variables or outputs, no file bytes, no recordings and no download links. When a handler needs any of those, it calls the Axilio API with your own key. That makes the events safe to log and pass between services, but it also means most handlers make at least one API call.

## Axilio webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard, **Settings > Webhooks > Register Destination** |
| Subscription model | Chosen event types, or all of them, per destination |
| Signature | Standard Webhooks HMAC-SHA256 |
| Headers | `webhook-id`, `webhook-timestamp` (Unix seconds), `webhook-signature` (`v1,` + base64) |
| Secret | `whsec_...`, one per destination, shown once |
| Body | JSON envelope `{id, type, occurred_at, test, data}` |
| Event routing | Body field `type` |
| Retries | After 30 seconds, 2 minutes, 5 minutes, 15 minutes and 1 hour |
| Timeout | Not stated |
| Ordering | Not guaranteed. `run.completed` can arrive before `run.started` |
| Delivery log | Every attempt and response, with replay for failed events |
| Test events | `webhook.test` from the dashboard, with `test: true` |
| Source IPs | Not published |

## Events and payloads

Every event shares one envelope:

| Field | Meaning |
| --- | --- |
| `id` | Unique per change; equals the `webhook-id` header. Format `<type>:<resource id>[:<time>]` |
| `type` | The event type |
| `occurred_at` | When the change happened (UTC) |
| `test` | `true` only for a test sent from the dashboard |
| `data` | The resource, with the same field names as the API. A field with no value yet is left out |

### Common Event Types

| Event | Triggered When | Notable `data` fields |
| --- | --- | --- |
| `run.started` | A queued run gets a phone and starts | `id`, `workflow_id`, `phone_id`, `session_id`, `status` |
| `run.completed` | A run finishes successfully | adds `completed_at`, `success` |
| `run.failed` | A run fails, or could not be queued or dispatched | same as completed |
| `run.cancelled` | A run stops early | `reason`: `user` or `queue_timeout` |
| `session.started` | A session gets a phone and goes live | `session_id`, `status`, `source`, `phone_id` |
| `session.ended` | A session ends and the phone is released | `end_reason`: `released`, `idle_timeout`, `max_duration`, `run_finished`, `device_lost` |
| `session.recording_ready` | A session recording finished uploading | `session_id`, `phone_id` |
| `balance.low` | Balance drops below your alert threshold | `alert_id`, `kind`, `threshold_microdollars`, `balance_microdollars`, `balance_display` |
| `balance.depleted` | Balance goes below zero | same as `balance.low` |
| `balance.recovered` | A balance alert ends | same as `balance.low` |
| `invoice.paid` | A subscription or rental invoice is paid | `stripe_invoice_id`, `invoice_number`, `amount_microdollars`, `currency`, `status`, `plan_name` |
| `invoice.payment_failed` | A payment attempt fails (one event per attempt) | same as `invoice.paid` |
| `subscription.updated` | Plan subscription status changes (for example `past_due`) | `subscription_id`, `plan_name`, `billing_cycle`, `status`, `cancel_at_period_end` |
| `subscription.canceled` | Plan subscription ends | same as `subscription.updated` |
| `auto_recharge.succeeded` | An automatic top-up is charged and credited | `stripe_invoice_id`, `amount_microdollars`, `balance_display` |
| `auto_recharge.failed` | An automatic top-up fails; auto-recharge is turned off | same as succeeded |
| `file.ready` | An upload completes or a captured file lands in your library | `id`, `filename`, `mime_type`, `size_bytes`, `source` (`upload` or `capture`) |
| `file.capture_failed` | A captured file never became a library file | adds `reason` |
| `file.delivered` | A file you pushed arrived on the phone | `id`, `file_id`, `phone_id`, `status`, `bytes_transferred` |
| `file.delivery_failed` | A pushed file did not arrive | adds `error` |
| `phone.maintenance_started` / `phone.maintenance_ended` | A rented phone leaves service for repair or update / returns | `phone_id`, `phone_name`, `status`, `reason`, `changed_at` |
| `phone.offline` / `phone.online` | A rented phone leaves service / is back | same as above |
| `phone.rental.activated` | A rental starts | `id`, `plan_name`, `phone_id`, `status`, `payment_status` |
| `phone.rental.past_due` | A rental payment fails | same as activated |
| `phone.rental.canceled` | A rental subscription ends | same as activated |
| `webhook.test` | Only when you send it | `message` |

`phone.*` events are sent only for phones your organisation rents. Money amounts are in microdollars, so `1000000` is $1.00 and `balance_display` gives you a formatted string. Axilio doesn't say this list is final, and `run.failed` asks you to fetch the run for its error message rather than including it.

Here is the `balance.low` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/axilio/latest/balance.low.json), which follows Axilio's documented example:

```json
{
  "id": "balance.low:0199a3c2-9a0b-7c1d-8e2f-3a4b5c6d7e8f",
  "type": "balance.low",
  "occurred_at": "2026-09-24T12:00:00Z",
  "test": false,
  "data": {
    "alert_id": "0199a3c2-9a0b-7c1d-8e2f-3a4b5c6d7e8f",
    "kind": "low_balance",
    "threshold_microdollars": 5000000,
    "balance_microdollars": 3200000,
    "balance_display": "$3.20"
  }
}

```

## Setting up Axilio webhooks

1. In the [Axilio dashboard](https://app.axilio.ai), open **Settings** > **Webhooks**.
2. Click **Register Destination** and enter a public `https://` URL.
3. Choose the event types, grouped by runs, sessions, billing, files and phones, or subscribe to all of them. A destination that only reacts to failed runs doesn't need session or file traffic.
4. Copy the signing secret, which starts with `whsec_`. Axilio shows it once, so store it as `AXILIO_WEBHOOK_SECRET` before you close the dialog.
5. Send a `webhook.test` event from the dashboard and confirm it verifies.

While developing, the [Hookdeck CLI](/docs/cli) gives you a public URL that forwards to your machine:

```bash
npx hookdeck-cli listen 3000 axilio --path /webhooks/axilio

```

**With Hookdeck.** Create an **Axilio** source in Hookdeck, paste the destination's full `whsec_` secret, and register the source URL as the destination in Axilio. The source takes `POST` requests only. Axilio always gets a `200` back from Hookdeck; a request with a bad signature is logged as failed verification in Hookdeck and goes no further.

## Securing Axilio webhooks

Axilio relies on Standard Webhooks and doesn't print its own construction. The signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`, the key is the base64-decoded remainder of the secret after `whsec_`, and `webhook-signature` holds one or more space-separated `v1,<base64>` values. The check below accepts any matching `v1` entry:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Axilio's `id` is built from the event type and resource, so a run's `run.completed` always has the same ID. That makes it a reliable deduplication key across retries and replays:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/axilio', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.AXILIO_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const event = parseJsonObject(req.body);
  if (!event) return res.sendStatus(400); // signed, but not a JSON object
  if (event.test) return res.sendStatus(200);
  if (typeof event.type !== 'string' || !event.data || typeof event.data !== 'object') return res.sendStatus(400);
  try {
    if (await seenBefore(event.id)) return res.sendStatus(200);

    switch (event.type) {
      case 'run.completed':
      case 'run.failed':
        await jobs.add('sync-run', { runId: event.data.id }); // fetch outputs or errors from the API
        break;
      case 'balance.low':
      case 'balance.depleted':
        await jobs.add('notify-finance', { balance: event.data.balance_display });
        break;
      default:
        break; // acknowledge types you don't handle yet
    }
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

```

`seenBefore` and `jobs` are placeholders for your own idempotency store and queue.

Axilio doesn't state a timestamp tolerance; it points you at the Standard Webhooks libraries, and those reject a `webhook-timestamp` more than 5 minutes from the current time. Hookdeck applies no tolerance and forwards Axilio's original headers on every retry and replay, so the library check would fail for events that waited behind an outage. The verifier above doesn't check age. If you add a window on direct traffic, size it beyond Axilio's 1-hour final retry.

> **Make Axilio webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies each Axilio delivery, stores the run, session and billing events, and retries them into your service on your schedule.

## Axilio webhook limitations and pain points

### Retries stop after about 80 minutes

**The Problem:** An outage longer than an hour or so means events stop arriving and aren't retried again.

**Why It Happens:** Axilio retries at 30 seconds, 2 minutes, 5 minutes, 15 minutes and 1 hour, then stops. Failed events stay in the delivery log, but someone has to replay them.

**Workarounds:**

* Check the dashboard delivery log after any incident and replay what failed.
* For runs, reconcile by listing recent runs through the API when your service comes back.

**How Hookdeck Can Help:** Hookdeck takes each delivery when it arrives, so Axilio sees success, and then retries delivery to your service for as long as your [retry policy](/docs/retries) allows.

### Events arrive out of order

**The Problem:** A run shows as started after you've already recorded it as completed.

**Why It Happens:** Axilio says outright that `run.completed` can arrive before `run.started`. Retries make reordering more likely.

**Workarounds:**

* Compare `occurred_at` with what you've stored and ignore older updates.
* Treat terminal states (`run.completed`, `run.failed`, `run.cancelled`) as final.

**How Hookdeck Can Help:** Hookdeck keeps each event's original headers and arrival time, which helps when you need to work out the real sequence.

### Payloads never include results

**The Problem:** `run.completed` arrives, but the run's output isn't in it.

**Why It Happens:** Axilio leaves content out of every payload by design. You get IDs and statuses only.

**Workarounds:**

* Fetch the run, file or recording through the API after verifying the event.
* Rate-limit those follow-up calls when many runs finish together.

**How Hookdeck Can Help:** Hookdeck's [delivery rate](/docs/destinations) setting can pace events into your service, which also paces the API calls each one triggers.

### One secret per destination, shown once

**The Problem:** Staging and production use different secrets, and the dashboard won't show either again.

**Why It Happens:** Axilio generates a secret per destination and displays it only at registration.

**Workarounds:**

* Store each destination's secret under a name that says which environment it belongs to.
* If one is lost, register a new destination and remove the old one.

**How Hookdeck Can Help:** Put one Hookdeck source per Axilio destination, and each secret is set once on its source rather than in every consumer.

## Best practices

### Verify the raw bytes

Use `express.raw({ type: '*/*' })`, `await request.text()` in a Next.js route handler, or `await request.body()` in FastAPI. Re-serialised JSON won't match the signature.

### Skip test events in production logic

Dashboard tests arrive with `test: true`. Acknowledge them and stop, so a test never triggers billing or run handling.

### Deduplicate on `id`

Delivery is at least once. The body `id` and the `webhook-id` header are the same value and stay fixed across retries. Our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) walks through the storage pattern.

### Respond before you call the API

Because most events lead to an API call, queue the work and answer `2xx` first. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Act on `auto_recharge.failed`

When a top-up fails, Axilio turns auto-recharge off until someone turns it back on. Alert a person rather than just logging the event, or the next `balance.depleted` will catch you out.

## Conclusion

Axilio's webhooks cover a lot of ground, from single runs to billing and rented hardware, but they all share one small envelope with no content in it. Verify with the destination's own secret, deduplicate on `id`, order by `occurred_at`, fetch details from the API, and keep an eye on the delivery log, because Axilio's retries end after about an hour.

If that hour isn't enough cover, [Hookdeck Event Gateway](https://hookdeck.com) can accept Axilio's events, verify them and hold them until your service catches up. [Create a free Hookdeck account](https://dashboard.hookdeck.com/signup) to set up an Axilio source.