Gareth Wilson Gareth Wilson

Guide to Axilio Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard, Settings > Webhooks > Register Destination
Subscription modelChosen event types, or all of them, per destination
SignatureStandard Webhooks HMAC-SHA256
Headerswebhook-id, webhook-timestamp (Unix seconds), webhook-signature (v1, + base64)
Secretwhsec_..., one per destination, shown once
BodyJSON envelope {id, type, occurred_at, test, data}
Event routingBody field type
RetriesAfter 30 seconds, 2 minutes, 5 minutes, 15 minutes and 1 hour
TimeoutNot stated
OrderingNot guaranteed. run.completed can arrive before run.started
Delivery logEvery attempt and response, with replay for failed events
Test eventswebhook.test from the dashboard, with test: true
Source IPsNot published

Events and payloads

Every event shares one envelope:

FieldMeaning
idUnique per change; equals the webhook-id header. Format <type>:<resource id>[:<time>]
typeThe event type
occurred_atWhen the change happened (UTC)
testtrue only for a test sent from the dashboard
dataThe resource, with the same field names as the API. A field with no value yet is left out

Common Event Types

EventTriggered WhenNotable data fields
run.startedA queued run gets a phone and startsid, workflow_id, phone_id, session_id, status
run.completedA run finishes successfullyadds completed_at, success
run.failedA run fails, or could not be queued or dispatchedsame as completed
run.cancelledA run stops earlyreason: user or queue_timeout
session.startedA session gets a phone and goes livesession_id, status, source, phone_id
session.endedA session ends and the phone is releasedend_reason: released, idle_timeout, max_duration, run_finished, device_lost
session.recording_readyA session recording finished uploadingsession_id, phone_id
balance.lowBalance drops below your alert thresholdalert_id, kind, threshold_microdollars, balance_microdollars, balance_display
balance.depletedBalance goes below zerosame as balance.low
balance.recoveredA balance alert endssame as balance.low
invoice.paidA subscription or rental invoice is paidstripe_invoice_id, invoice_number, amount_microdollars, currency, status, plan_name
invoice.payment_failedA payment attempt fails (one event per attempt)same as invoice.paid
subscription.updatedPlan subscription status changes (for example past_due)subscription_id, plan_name, billing_cycle, status, cancel_at_period_end
subscription.canceledPlan subscription endssame as subscription.updated
auto_recharge.succeededAn automatic top-up is charged and creditedstripe_invoice_id, amount_microdollars, balance_display
auto_recharge.failedAn automatic top-up fails; auto-recharge is turned offsame as succeeded
file.readyAn upload completes or a captured file lands in your libraryid, filename, mime_type, size_bytes, source (upload or capture)
file.capture_failedA captured file never became a library fileadds reason
file.deliveredA file you pushed arrived on the phoneid, file_id, phone_id, status, bytes_transferred
file.delivery_failedA pushed file did not arriveadds error
phone.maintenance_started / phone.maintenance_endedA rented phone leaves service for repair or update / returnsphone_id, phone_name, status, reason, changed_at
phone.offline / phone.onlineA rented phone leaves service / is backsame as above
phone.rental.activatedA rental startsid, plan_name, phone_id, status, payment_status
phone.rental.past_dueA rental payment failssame as activated
phone.rental.canceledA rental subscription endssame as activated
webhook.testOnly when you send itmessage

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

  1. In the Axilio dashboard, 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 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_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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.