Docs / AI Box And Video Analytics Software / Webhooks
Webhooks
Receive an AI Box's detection events, periodic summaries and alerts in your own system, verify their signature, and handle retries.
An AI Box can push data to your system as it happens, so you do not have to poll the API. Every message is an HTTPS
POST with a JSON body, signed the Standard Webhooks way.
Two Kinds Of Webhooks
- Webhooks for integrators belong to an API account. A subscription picks the product’s event types and the sites, and receives detection events in batches, periodic summaries and alerts. This guide is mostly about them.
- Alert webhooks belong to a site. An admin adds them on the dashboard (Webhooks page) or with
POST /api/v1/sites/{id}/webhooks, and each alert is posted at once, for example to a video management system or a gate. They are signed the same way.
Create A Subscription
A subscription belongs to an API account, and what it sends follows that account: only its sites, and plates, names and
photos only when it has the personal_data scope. The box’s owner or an admin of the account’s sites can create one on
the dashboard (API accounts page, Webhooks), or your system can create its own with the account’s token and the
write scope.
First list the event types of the box’s product:
curl --cacert box.pem \
-H "Authorization: Bearer <API_KEY>" \
"https://<BOX_HOST>/api/v1/webhook-event-types"
Then subscribe your receiver:
curl --cacert box.pem \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"name": "ERP receiver", "url": "https://<RECEIVER_HOST>/hooks/arsa", "event_types": ["traffic.passage", "traffic.speeding"], "sites": [1], "summary": "1h", "alerts": true}' \
"https://<BOX_HOST>/api/v1/webhook-subscriptions"
The answer holds the subscription and its signing secret, whsec_ followed by base64 text. The secret is shown only in
this answer: store it as a secret in your receiver. POST /api/v1/webhook-subscriptions/{id}/secret makes a new one,
and the old one stops at once.
| Field | Meaning |
|---|---|
url |
Your receiver, https:// or http://. Addresses inside the box are refused; private networks only when the box’s owner allows them |
event_types |
The detection events to send; [] sends none |
sites |
Site ids of the account to include; [] means all of them |
batch_seconds, batch_max |
A batch is sent after batch_seconds (default 10) or once it holds batch_max events (default 100), whichever comes first |
summary |
Periodic summaries: 15m, 1h, 1d, or null for none |
alerts |
Whether the account’s alerts are sent too |
active |
false pauses the subscription; when resumed it sends from then on |
An account can have up to 10 subscriptions. POST /api/v1/webhook-subscriptions/{id}/test sends a signed test
message now and reports your receiver’s answer. The test stays in the subscription’s delivery log as Test, with
your receiver’s answer; it is not sent again.
Event Types
| Product | Event types |
|---|---|
| Basic Safety Guard | bsg.violation, bsg.hazard_entry |
| Traffic Monitor | traffic.passage, traffic.speeding, traffic.wrong_way, traffic.stopped, traffic.congestion, traffic.watchlist |
| Smart Retail Counter | retail.visit, retail.crossing, retail.queue, retail.floor, retail.vip |
| DOOH Audience Meter | dooh.view, dooh.play |
GET /api/v1/webhook-event-types lists them with their labels and the fields that are personal data (personal).
Those fields are null for an account without personal_data. For Basic Safety Guard, worker names and evidence go
only to an account that is also an admin of the event’s site, and only while the site’s owner has accountability on.
For Smart Retail Counter, retail.floor is the shop floor’s dwell time per minute and camera: the floor area, its grid
and the seconds spent in each cell, with no personal data. retail.vip is a VIP sighting with the VIP’s name. It is
about a person (personal_only in the list), so it goes only to an account with personal_data, and only while the
store’s owner has staff and VIP recognition on; an account without personal_data gets none of these events.
The Message
Each POST carries three headers and a JSON body:
| Header | Meaning |
|---|---|
webhook-id |
The message id, the same on every retry of this message |
webhook-timestamp |
When this attempt was signed, in Unix seconds |
webhook-signature |
v1, and the base64 HMAC-SHA256 signature (see below) |
The body is an envelope with the message type, its timestamp (RFC 3339) and its data:
type |
Sent | data |
|---|---|---|
events.batch |
Every batch_seconds or batch_max events |
subscription_id, account, personal_data, product, count and events; each event has id, type, time, site_id, camera_id and its data |
summary |
Every 15 minutes, hour or day of each site’s time zone | subscription_id, account, product, site_id, site, timezone, period, from, to and figures, the product’s overview for the period |
alert.raised |
When an alert is raised | The alert, as the alert webhooks send it |
test |
On POST .../test |
subscription_id, product |
Each event’s id is stable, so a receiver can drop an event it has already stored.
Payload Examples
All values below are made up. The examples are for an account without personal_data, so personal fields are null.
A batch of Traffic Monitor events:
{
"type": "events.batch",
"timestamp": "2026-11-06T03:15:10.000Z",
"data": {
"subscription_id": 4,
"account": "erp-receiver",
"personal_data": false,
"product": "traffic",
"count": 2,
"events": [
{
"id": "01JC8Q2W7K3M5N9P4R6T8V0X2Z",
"type": "traffic.passage",
"time": "2026-11-06T03:15:02.412Z",
"site_id": 1,
"camera_id": 3,
"data": {"track": "3-118", "line": "count", "direction": "a_to_b", "lane": "L1", "class": "car", "colour": "white", "speed_kmh": 52.0, "plate": null, "photo": null}
},
{
"id": "01JC8Q2X1B4D6F8H0J2L4N6Q8S",
"type": "traffic.speeding",
"time": "2026-11-06T03:15:06.090Z",
"site_id": 1,
"camera_id": 3,
"data": {"track": "3-121", "lane": "L2", "class": "motorcycle", "speed_kmh": 81.0, "limit_kmh": 60, "plate": null}
}
]
}
}
An hourly summary; figures holds the same figures as the product’s overview for that hour:
{
"type": "summary",
"timestamp": "2026-11-06T03:01:00.000Z",
"data": {
"subscription_id": 4,
"account": "erp-receiver",
"product": "traffic",
"site_id": 1,
"site": "<SITE_NAME>",
"timezone": "Asia/Jakarta",
"period": "1h",
"from": "2026-11-06T02:00:00.000Z",
"to": "2026-11-06T03:00:00.000Z",
"figures": {"vehicles": 1184, "by_class": {"car": 540, "motorcycle": 489, "truck": 96, "bus": 37, "bicycle": 22}, "flow_per_min": 19.6, "speeds_measured": 980, "mean_speed_kmh": 47.3, "p85_speed_kmh": 58.0, "plates_read_pct": 0.0, "incidents": {"speeding": 12, "wrong_way": 0, "stopped": 1, "congestion": 0, "watchlist": 0}, "congested_now": []}
}
}
An alert, with the same data as the alert webhooks send (here from Basic Safety Guard):
{
"type": "alert.raised",
"timestamp": "2026-11-06T03:16:41.000Z",
"data": {
"id": 57,
"site_id": 1,
"site": "<SITE_NAME>",
"camera_id": 2,
"camera": "<CAMERA_NAME>",
"kind": "ppe.missing",
"severity": "critical",
"first": "2026-11-06T03:16:39.200Z",
"event_id": "01JC8Q5A9C2E4G6J8L0N2Q4S6U",
"details": {"missing": ["hard_hat"], "area": "<AREA_NAME>"},
"link": "https://<BOX_HOST>/alerts/inbox"
}
}
The fields of each event type’s data are listed by GET /api/v1/webhook-event-types; the product’s API reference
describes the same fields for its log.
Verify The Signature
The signature is the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, made with the secret’s bytes:
decode the base64 text after whsec_. Check it on the raw body exactly as received, before you parse the JSON. The
header may hold several signatures separated by spaces; accept the message when one of them matches. Refuse a timestamp
more than 5 minutes from your clock, so a recorded message cannot be replayed later.
Python, with the standard library only:
import base64
import hashlib
import hmac
import time
TOLERANCE_S = 5 * 60
def verify(secret, msg_id, timestamp, signatures, body):
"""True when the webhook-signature header holds a valid signature of the raw body (bytes)."""
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_S:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
except ValueError:
return False
signed = msg_id.encode() + b"." + timestamp.encode() + b"." + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for sig in signatures.split(" "):
version, _, value = sig.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return False
A receiver with the standard library’s HTTP server:
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"]
seen = set() # use your database in production
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
h = self.headers
if not verify(SECRET, h.get("webhook-id", ""), h.get("webhook-timestamp", "0"),
h.get("webhook-signature", ""), body):
self.send_response(401)
self.end_headers()
return
if h["webhook-id"] not in seen:
seen.add(h["webhook-id"])
message = json.loads(body)
print(message["type"], message["data"]) # hand it to your system here
self.send_response(204)
self.end_headers()
HTTPServer(("", 8080), Receiver).serve_forever()
JavaScript, with the Node.js crypto module only:
const crypto = require("node:crypto");
const TOLERANCE_S = 5 * 60;
// True when the webhook-signature header holds a valid signature of the raw body (a Buffer).
function verify(secret, msgId, timestamp, signatures, body) {
if (!msgId || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_S) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${msgId}.${timestamp}.`)
.update(body)
.digest();
return signatures.split(" ").some((sig) => {
const [version, value] = sig.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
A receiver with the Node.js http module:
const http = require("node:http");
const SECRET = process.env.WEBHOOK_SECRET;
const seen = new Set(); // use your database in production
http
.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
const h = req.headers;
if (!verify(SECRET, h["webhook-id"], h["webhook-timestamp"], h["webhook-signature"], body)) {
res.writeHead(401).end();
return;
}
if (!seen.has(h["webhook-id"])) {
seen.add(h["webhook-id"]);
const message = JSON.parse(body);
console.log(message.type, message.data); // hand it to your system here
}
res.writeHead(204).end();
});
})
.listen(8080);
Answer, Retries And Repeats
- Answer with a 2xx status within a few seconds to accept a message. Store it and do the work afterwards.
- Retries: a
408,425,429or5xxanswer, or no answer, is retried with a growing wait: 30 seconds, doubling up to an hour, or the time in yourRetry-Afterheader. Messages stay in order: later ones wait behind the one being retried. - After 24 hours a message that still fails is dropped, and the subscription’s
healthshowsfailing. - Other answers: any other
4xx, or a redirect, fails the message at once. Redirects are never followed. - The delivery log (
GET /api/v1/webhook-subscriptions/{id}/deliveries) shows every message and its last answer;POST /api/v1/webhook-deliveries/{id}/retrysends a failed or waiting one again within those 24 hours. - At least once: a message can arrive twice, for example after the box restarts mid-send. It keeps its
webhook-id, so drop the ones you have seen.
Nothing is sent while the API account is revoked, expired or without the read scope.
Signing Requests To The Box
Three feeds go the other way: your system posts to the box and signs the request with a key of the site, in the same Standard Webhooks form. An admin of the site makes the key on the dashboard; it is shown once.
| Feed | Operation | Key |
|---|---|---|
| Plate watchlist from another system | POST /api/traffic/v1/feeds/watchlist/{site} |
The site’s watchlist key |
| Transactions from the store’s POS | POST /api/retail/v1/pos/{site} |
The site’s POS key |
| Play logs from a CMS or player | POST /api/dooh/v1/feeds/plays/{site} |
The site’s play-log key |
Send a new webhook-id for every request (a repeated one is refused), the current time as webhook-timestamp (it must
be within 5 minutes of the box’s clock), and the signature of the exact body you send.
Python:
import base64
import hashlib
import hmac
import time
import uuid
def sign(key, body):
"""The three headers for a request to the box; key is the site's whsec_ key, body the bytes you send."""
msg_id = "msg_" + uuid.uuid4().hex
timestamp = str(int(time.time()))
secret = base64.b64decode(key.removeprefix("whsec_"))
signed = msg_id.encode() + b"." + timestamp.encode() + b"." + body
signature = base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest()).decode()
return {"webhook-id": msg_id, "webhook-timestamp": timestamp, "webhook-signature": "v1," + signature}
JavaScript:
const crypto = require("node:crypto");
// The three headers for a request to the box; key is the site's whsec_ key, body the string you send.
function sign(key, body) {
const msgId = "msg_" + crypto.randomUUID().replaceAll("-", "");
const timestamp = String(Math.floor(Date.now() / 1000));
const secret = Buffer.from(key.replace(/^whsec_/, ""), "base64");
const signature = crypto.createHmac("sha256", secret).update(`${msgId}.${timestamp}.${body}`).digest("base64");
return { "webhook-id": msgId, "webhook-timestamp": timestamp, "webhook-signature": `v1,${signature}` };
}
A watchlist update, for example:
BODY='{"upsert": [{"plate": "B 1234 XYZ", "reason": "tax_arrears"}], "remove": ["D 5678 AB"]}'
curl --cacert box.pem \
-H "Content-Type: application/json" \
-H "webhook-id: <MESSAGE_ID>" \
-H "webhook-timestamp: <UNIX_SECONDS>" \
-H "webhook-signature: v1,<SIGNATURE>" \
-d "$BODY" \
"https://<BOX_HOST>/api/traffic/v1/feeds/watchlist/1"
A wrong signature, a timestamp too far off, a repeated id and a site without a key all answer 401.
1.0.0. Updated 2026-11-06