DocsIDarsa.technology

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, 429 or 5xx answer, or no answer, is retried with a growing wait: 30 seconds, doubling up to an hour, or the time in your Retry-After header. 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 health shows failing.
  • 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}/retry sends 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.

Was this page helpful?

1.0.0. Updated 2026-11-06