Webhooks

BriefQR pushes signed events to endpoints you register. Subscribe, verify the signature, and return 2xx.

Subscribe

curl -X POST https://api.briefqr.com/v1/webhook-endpoints 
  -H "Authorization: Bearer bqr_test_..." 
  -H "Content-Type: application/json" 
  -d '{
        "url": "https://example.com/hooks/briefqr",
        "enabled_events": ["session.completed", "session.flagged"]
      }'

The response includes the signing secret (whsec_…) once. enabled_events accepts exact types, a resource.* wildcard (e.g. operator.*), or *. Send yourself a test delivery any time:

curl -X POST https://api.briefqr.com/v1/webhook-endpoints/{id}/send-test 
  -H "Authorization: Bearer bqr_test_..."

The event payload

Each delivery is a POST with this body:

{
  "id": "evt_9c1f…",
  "type": "session.completed",
  "api_version": "2026-06-23",
  "livemode": false,
  "created": "2026-06-23T14:02:11.482Z",
  "data": { "object": { "object": "session", "id": "…", "total_hours": 7.5, "...": "..." } }
}

And these headers:

HeaderValue
X-BriefQR-Signaturet=<unix>,v1=<hex> (see below)
X-BriefQR-Idwhd_… — the delivery id, stable across retries
X-BriefQR-Eventthe event type

Dedupe on the envelope id (or X-BriefQR-Id) — a delivery may arrive more than once.

Verify the signature

The signature header is t=<timestamp>,v1=<hmac>. Compute HMAC-SHA256 over the string `${t}.${rawBody}` (the unix timestamp, a literal dot, then the exact raw request body), keyed by your endpoint secret, and compare hex digests in constant time. Reject if the timestamp is more than 5 minutes from now.

During a secret rotation the header carries two v1= values (new and previous secret). Accept the delivery if either matches.

Node / Express

const crypto = require("crypto");

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=").map((s) => s.trim()))
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  // header may hold several v1= values during rotation
  const provided = header.split(",").filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
  return provided.some((v) =>
    v.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected))
  );
}

// Express — read the RAW body, not the parsed JSON
app.post("/hooks/briefqr", express.raw({ type: "*/*" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.headers["x-briefqr-signature"], process.env.BRIEFQR_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(raw);
  if (event.type === "session.completed") { /* … */ }
  res.sendStatus(200);
});

Python / Flask

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    provided = [p[3:] for p in header.split(",") if p.startswith("v1=")]
    return any(hmac.compare_digest(v, expected) for v in provided)

@app.post("/hooks/briefqr")
def hook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers["X-BriefQR-Signature"], os.environ["BRIEFQR_WEBHOOK_SECRET"]):
        return "", 400
    event = request.get_json()
    return "", 200

The official SDKs ship a constructEvent / construct_event helper that does exactly this.

Event types

session.completed, session.updated, session.deleted, session.flagged, operator.created, operator.updated, operator.deactivated, device.created, device.updated, device.deleted, location.created, location.updated, location.deleted, scan.checked_in, scan.checked_out, scan.flagged.

Retries

A delivery is retried with exponential backoff (up to 6 attempts, ~60s → 1h) until your endpoint returns 2xx. Return 410 Gone to stop retries permanently. After sustained failures an endpoint is auto-disabled — re-enable it with a PATCH. Inspect and replay deliveries in the delivery log or via POST /v1/webhook-deliveries/{id}/resend.

Rotating the secret

curl -X POST https://api.briefqr.com/v1/webhook-endpoints/{id}/rotate-secret 
  -H "Authorization: Bearer bqr_test_..." 
  -d '{ "grace_hours": 24 }'

The new secret is returned once. For grace_hours the previous secret stays valid too (deliveries are signed with both), so you can roll the secret on your receiver without dropping events.