Guide to Axilio Webhooks: Features and Best Practices
Axilio 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_ |
| 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_ / 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, which follows Axilio's documented example:
{
"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
- In the Axilio dashboard, open Settings > Webhooks.
- Click Register Destination and enter a public
https://URL. - 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.
- Copy the signing secret, which starts with
whsec_. Axilio shows it once, so store it asAXILIO_WEBHOOK_SECRETbefore you close the dialog. - Send a
webhook.testevent from the dashboard and confirm it verifies.
While developing, the Hookdeck CLI gives you a public URL that forwards to your machine:
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:
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:
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 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 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_atwith 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 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 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.
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 can accept Axilio's events, verify them and hold them until your service catches up. Create a free Hookdeck account to set up an Axilio source.