Webhooks tell your server when something finishes — an activation completes, a fleet is ready, an invoice is paid — so you do not have to poll. Each endpoint belongs to one mode and receives only the events it subscribes to, within the fleets it may see.
Register an endpoint
- Portal: Webhooks → Add endpoint, or
POST /webhook-endpointswithurl,event_types(or["*"]for everything you may receive), optionalfleet_idsanddescription. - Requirements:
https://, port 443 or 8443, a public address (private, loopback, link-local, metadata and reserved addresses are refused, and so are IP literals in unusual notation). Redirects are never followed. - The response contains the signing secret
whsec_…once. - The endpoint stays
pendinguntil it answers the signed test event (POST /webhook-endpoints/{id}/test, or the portal button) with a 2xx status. Only then does it receive events. - Changing the URL or re-enabling a disabled endpoint returns it to
pending.
Event catalogue
| Type | When | Creator needs | Scope |
|---|---|---|---|
customer.ready | The first fleet of a customer is ready: the customer can be used for activations. | customers.read | Fleet |
customer.provisioning_failed | Setting up the first fleet of a customer failed. | customers.read | Fleet |
fleet.ready | A fleet is provisioned and ready for devices and activations. | fleets.read | Fleet |
fleet.provisioning_failed | Provisioning a fleet failed; see the operation for details. | fleets.read | Fleet |
fleet.plan_changed | A fleet-level plan change completed. | fleets.read | Fleet |
service.activation_completed | A tracking service is confirmed active; the usage charge starts at effective_at. | devices.read | Fleet |
service.activation_failed | An activation failed permanently; nothing is charged. | devices.read | Fleet |
service.deactivation_completed | A service is confirmed deactivated; the usage charge stops at effective_at. | devices.read | Fleet |
service.deactivation_failed | A deactivation could not be completed; the service is still active and chargeable. | devices.read | Fleet |
service.reactivation_completed | An inactive service is active again (a new usage period starts). | devices.read | Fleet |
service.plan_changed | The plan of a service changed at effective_at. | devices.read | Fleet |
operation.requires_attention | An operation needs Fleetalyse or partner attention (it will not retry by itself). | operations.read | Fleet |
operation.cancelled | An operation was cancelled or superseded by a newer request. | operations.read | Fleet |
invoice.finalised | An invoice was finalised and is due. | billing.read | Partner |
invoice.paid | An invoice was paid. | billing.read | Partner |
invoice.payment_failed | Automatic collection of an invoice failed. | billing.read | Partner |
invoice.payment_action_required | Your bank needs you to authenticate a payment. | billing.read | Partner |
partner.billing_restricted | New charge-creating actions are paused until the balance is paid. | billing.read | Partner |
partner.billing_restored | The billing restriction was lifted. | billing.read | Partner |
hardware.order_dispatched | A hardware order (or part of it) was dispatched. | hardware.read | Partner |
test.ping | The signed test event sent from the portal or POST /webhook-endpoints/{id}/test. | — | Test only |
Fleet events (customer, fleet, service, operation) carry a fleet and reach endpoints for that fleet. Partner events (invoice, partner, hardware) have no fleet and go only to endpoints for all fleets whose creator holds the scope. An endpoint limited to selected fleets can subscribe to fleet events only.
Payload
{
"id": "evt_example_001",
"type": "service.activation_completed",
"schema_version": "1",
"created_at": "2026-10-01T09:30:02Z",
"livemode": true,
"resource": { "type": "service", "id": "svc_example_01", "version": 3 },
"data": {
"customer_id": "cus_example_a",
"fleet_id": "flt_example_a",
"device_id": "dev_example_01",
"service_id": "svc_example_01",
"operation_id": "op_example_1042",
"plan_code": "pro",
"status": "active",
"billing_status": "accruing",
"external_order_reference": "ORDER-1042",
"effective_at": "2026-10-01T09:30:00Z"
}
}Headers: Fleetalyse-Signature, Fleetalyse-Event-Id, Fleetalyse-Event-Type, Fleetalyse-Delivery-Attempt, Fleetalyse-Mode and, for replays, Fleetalyse-Replay: true. resource.version increases with every change of the resource.
Verify the signature
The header looks like t=1791890402,v1=5257a869…. Compute HMAC-SHA256 with your signing secret (the whole whsec_… string) over <t>.<raw request body>, compare it in constant time with every v1 value, and reject timestamps more than 300 seconds from your clock. Always use the raw body bytes — re-encoding the JSON breaks the signature.
<?php
// Verify a Fleetalyse webhook (PHP 8). $secret = your whsec_… signing secret.
function fleetalyse_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't' && ctype_digit($v)) {
$t = (int)$v;
} elseif ($k === 'v1') {
$signatures[] = $v;
}
}
if ($t === null || $signatures === [] || abs(time() - $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input'); // the raw body, unchanged
if (!fleetalyse_verify($raw, $_SERVER['HTTP_FLEETALYSE_SIGNATURE'] ?? '', getenv('FLEETALYSE_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
if (already_processed($event['id'])) { // your dedupe table
http_response_code(200);
exit;
}
queue_for_processing($event); // do the work after answering
http_response_code(204);// Verify a Fleetalyse webhook (Node.js 18+, Express). Keep the RAW body.
const crypto = require('crypto');
const express = require('express');
const app = express();
function fleetalyseVerify(rawBody, header, secret, tolerance = 300) {
let t = null;
const sigs = [];
for (const part of String(header || '').split(',')) {
const [k, v] = part.trim().split('=', 2);
if (k === 't' && /^\d+$/.test(v)) t = Number(v);
else if (k === 'v1' && /^[0-9a-f]{64}$/.test(v)) sigs.push(v);
}
const nowSeconds = Math.floor(new Date().getTime() / 1000);
if (t === null || sigs.length === 0 || Math.abs(nowSeconds - t) > tolerance) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return sigs.some((s) => crypto.timingSafeEqual(Buffer.from(s, 'hex'), Buffer.from(expected, 'hex')));
}
app.post('/webhooks/fleetalyse', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!fleetalyseVerify(raw, req.get('Fleetalyse-Signature'), process.env.FLEETALYSE_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
res.sendStatus(204); // answer first …
handleOnce(event); // … then process, skipping event ids you have already seen
});# Verify a Fleetalyse webhook (Python 3, Flask). Keep the RAW body.
import hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
def fleetalyse_verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
t, sigs = None, []
for part in (header or '').split(','):
k, _, v = part.strip().partition('=')
if k == 't' and v.isdigit():
t = int(v)
elif k == 'v1':
sigs.append(v)
if t is None or not sigs or abs(int(time.time()) - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
@app.post('/webhooks/fleetalyse')
def fleetalyse_webhook():
raw = request.get_data() # bytes exactly as sent
if not fleetalyse_verify(raw, request.headers.get('Fleetalyse-Signature', ''), os.environ['FLEETALYSE_WEBHOOK_SECRET']):
return '', 400
event = request.get_json()
enqueue_once(event) # dedupe on event['id'], process asynchronously
return '', 204Respond, retry, disable
- Answer with any 2xx within 10 seconds, then do the work. Anything else (including redirects and timeouts) counts as a failure.
- Failed deliveries are retried with backoff and jitter: 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 6 h (then every 6 h) until the event is 72 hours old.
- After 3 failures in a row an endpoint gets one delivery at a time, at most every 30 seconds and backing off to every 10 minutes; the others wait and follow as soon as one succeeds. Queued events of other endpoints are never held up by it.
- Delivery is at-least-once and not ordered: the same event can arrive twice, and a later event can arrive first.
- An endpoint with no successful delivery for 72 hours is disabled and the account owner is emailed. Operations can still be polled and missed events listed with
GET /events.
Deduplicate and order
- Store the event
idof every event you processed (a unique index is enough) and skip ids you have seen. Replays and retries keep the same id. - When order matters, compare
resource.versionwith what you stored, or fetch the resource (GET /services/{id},GET /operations/{id}) and act on its current state.
Rotate the signing secret
POST /webhook-endpoints/{id}/rotate-secret returns a new secret. For overlap_seconds (default 24 hours, up to 7 days) every request is signed with both secrets — two v1 values — so you can deploy the new secret without dropping events. In live mode the portal asks for your two-factor code before revealing or rotating a secret.
Delivery log and replay
GET /webhook-endpoints/{id}/deliverieslists attempts: status, attempts, last HTTP status, a redacted excerpt of your response and the next attempt.POST /events/{id}/replay(optionally withendpoint_id) redelivers an event with the same id and body — up to 20 replays per endpoint per hour.GET /eventsandGET /events/{id}list events your key may see, newest first (filterstype,created_after,created_before).