Skip to content

Integrations (Webhooks)

Qbee integrations let you receive real-time notifications about events in your account by delivering them to an HTTPS endpoint you control (a webhook). When something happens to one of your devices — it is added, deleted, moved, comes online, records a log, etc. — Qbee sends a signed HTTP POST request to your endpoint so you can react to it in your own systems.

Qbee uses thin events: each event contains only the identifier of the affected resource (for example a device_id), the event type and a timestamp — not a full snapshot of the resource. When you need the full, current state of a resource, look it up through the public API using the identifier from the event. This keeps payloads small, avoids leaking stale data, and guarantees you always act on the latest state.

Overview

  • Events are delivered as POST requests with a Content-Type: application/json body.
  • Every request is signed with a per-integration secret using a JWT in the Qbee-Signature header (HMAC-SHA256).
  • Delivery is at-least-once: failed deliveries are retried, so your endpoint must be idempotent and deduplicate on the event id.
  • Only public HTTPS endpoints are accepted as targets.

Creating an integration

You can manage integrations from the Qbee portal under Integrations.

When you create an integration you configure:

  • Target URL — the public HTTPS endpoint that will receive events.
  • Events — one or more event types you want to subscribe to (see Supported event types).
  • For device:log events only, an optional minimum severity and a set of labels to filter on.

When the integration is saved, Qbee generates a webhook secret. This secret is used to sign the Qbee-Signature header on every outgoing request, and is how you verify that a delivery genuinely originates from Qbee. Store it securely — treat it like a password.

A few limits and rules to be aware of:

  • Up to 5 integrations can be configured per account.
  • The target must use the https:// scheme.
  • The target must be publicly resolvable. URLs that resolve to private, loopback, or link-local addresses (for example localhost, 10.0.0.0/8, 192.168.0.0/16, 169.254.0.0/16) are rejected. HTTP redirects are not followed.

The event envelope

Every request body has the same top-level structure:

Field Type Description
id string Unique event ID. Use it to deduplicate events across retries.
timestamp string Event timestamp in RFC 3339 / ISO 8601 format (UTC).
type string The event type, e.g. device:added.
subscription_id string The integration this event was delivered for. Use it to select the correct signing secret.
account_id string Your Qbee account ID the event originates from.
payload object Event-specific data. For thin events this is the resource identifier (e.g. device_id).

Example (device:added):

{
  "id": "665f1b2c3a4d5e6f7a8b9c0d",
  "timestamp": "2026-09-09T12:34:56.789Z",
  "type": "device:added",
  "subscription_id": "0f4c9b7e-1d2a-4b3c-8e5f-6a7b8c9d0e1f",
  "account_id": "5f8d0a2b3c4e5f6a7b8c9d0e",
  "payload": {
    "device_id": "b3d4a1f0c9e8d7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2"
  }
}

The payload values are always strings. All device events reference the device through the device_id field, which is the device's public key digest (the same identifier used throughout the Qbee API).

Responses, timeouts and retries

  • Your endpoint should respond with any 2xx status code to acknowledge receipt.
  • Each delivery attempt has a 5 second timeout. Do the minimum work needed to accept and store the event, then process it asynchronously.
  • Any non-2xx response, network error, or timeout is treated as a failure and the event is retried.

Failed deliveries are retried up to 5 times with exponential backoff and jitter, approximately on this schedule:

Retry Approx. delay after previous attempt
1 ~5 seconds
2 ~30 seconds
3 ~1 minute
4 ~5 minutes
5 ~15 minutes

Because the same event may be delivered more than once, deduplicate on the id field and make your processing idempotent.

Automatic disabling (circuit breaker)

To protect both sides, an integration that keeps failing is automatically disabled: if many events in a row exhaust all their retries, Qbee disables the integration and sends an alert email to the account contact. Re-enable it from the portal (or via the API) once your endpoint is healthy again. You can then use the retrigger action to re-deliver events you missed.

Supported event types

Device events are thin — the payload contains only device_id — except device:log, which additionally carries the log details.

Event type Triggered when Payload fields
device:added A device finishes bootstrapping and is added to your inventory. device_id
device:deleted A device is removed from your inventory. device_id
device:group A device is moved to a different group. device_id
device:tags A device's tags are changed. device_id
device:attributes A device's attributes are updated (via the API or by the agent). device_id
device:online A device comes online. Requires heartbeat monitoring to be enabled. device_id
device:offline A device goes offline. Requires heartbeat monitoring to be enabled. device_id
device:log A device records a log/report matching the configured severity and labels. device_id, report_id, severity, label, message

device:online and device:offline are only emitted when heartbeat monitoring is enabled in the device configuration (Settings bundle).

device:log payload and filtering

device:log events carry the log details in addition to the device identifier:

Field Description
device_id The device that produced the log.
report_id Identifier of the underlying report (look it up via the API for detail).
severity One of INFO, WARN, ERR, CRIT.
label The report label (e.g. heartbeat). May be an empty string.
message The human-readable log message.

Example:

{
  "id": "665f1b2c3a4d5e6f7a8b9c0d",
  "timestamp": "2026-09-09T12:34:56.789Z",
  "type": "device:log",
  "subscription_id": "0f4c9b7e-1d2a-4b3c-8e5f-6a7b8c9d0e1f",
  "account_id": "5f8d0a2b3c4e5f6a7b8c9d0e",
  "payload": {
    "device_id": "b3d4a1f0c9e8d7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2",
    "report_id": "665f1a1b2c3d4e5f6a7b8c9d",
    "severity": "WARN",
    "label": "heartbeat",
    "message": "Device heartbeat expired"
  }
}

When subscribing to device:log you can narrow down which logs you receive:

  • Severity — the minimum severity to deliver, ordered INFO < WARN < ERR < CRIT. You receive logs at or above this level. If not set, the default is ERR.
  • Labels — an optional list of labels to match. If empty, all labels match.

Verifying the signature

Every delivery includes a Qbee-Signature header containing a JWT signed with HMAC-SHA256 (HS256) using your integration's webhook secret. Verifying it lets you confirm the request came from Qbee and was not tampered with in transit.

The header is a standard compact JWT with three base64url-encoded segments:

<header>.<claims>.<signature>

Header:

{ "alg": "HS256", "typ": "JWT" }

Claims:

Claim Description
iss Always qbee.
sub The integration (subscription) ID. Matches subscription_id in the body.
org Your account ID. Matches account_id in the body.
event_id The event ID. Matches the id field in the body — use this for deduplication.
event_type The event type. Matches type in the body.
jti Unique per delivery attempt — it changes on every retry. Do not use it to dedupe.
iat Issued-at time (Unix seconds).
exp Expiry time (Unix seconds), 5 minutes after iat.
body_sha256 base64url-encoded SHA-256 digest of the raw request body. Binds the signature to the payload.

Because the JWT signature covers only the header and claims, the request body is bound to the signature through the body_sha256 claim. A complete verification therefore has three steps:

  1. Verify the JWT signature — recompute HMAC-SHA256(header + "." + claims) with your webhook secret and compare it (in constant time) to the third segment.
  2. Verify the body digest — compute the SHA-256 of the exact raw request body, base64url-encode it (no padding), and compare it to the body_sha256 claim.
  3. (Recommended) Check freshness and identity — reject the request if exp is in the past, and confirm iss is qbee and sub matches the integration you expect.

Use the raw request bytes for the digest — parsing and re-serializing the JSON can change bytes (key order, whitespace) and break verification.

Example: Node.js

const crypto = require('crypto');

/**
 * @param {Buffer} rawBody          exact bytes of the request body
 * @param {string} signatureHeader  value of the "Qbee-Signature" header
 * @param {string} secret           the integration's webhook secret
 */
function verifyQbeeWebhook(rawBody, signatureHeader, secret) {
  const parts = signatureHeader.split('.');
  if (parts.length !== 3) {
    throw new Error('malformed signature');
  }
  const [headerB64, claimsB64, signatureB64] = parts;

  // 1. Verify the HMAC-SHA256 signature over "header.claims".
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${headerB64}.${claimsB64}`)
    .digest();
  const provided = Buffer.from(signatureB64, 'base64url');
  if (
    expected.length !== provided.length ||
    !crypto.timingSafeEqual(expected, provided)
  ) {
    throw new Error('invalid signature');
  }

  const claims = JSON.parse(Buffer.from(claimsB64, 'base64url').toString('utf8'));

  // 2. Bind the signature to the received body.
  const bodyDigest = crypto.createHash('sha256').update(rawBody).digest('base64url');
  if (bodyDigest !== claims.body_sha256) {
    throw new Error('body digest mismatch');
  }

  // 3. Check freshness and issuer.
  if (claims.iss !== 'qbee') {
    throw new Error('unexpected issuer');
  }
  if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) {
    throw new Error('signature expired');
  }

  return claims;
}

Example: Python

import base64
import hashlib
import hmac
import json
import time


def _b64url_decode(segment: str) -> bytes:
    padding = "=" * (-len(segment) % 4)
    return base64.urlsafe_b64decode(segment + padding)


def verify_qbee_webhook(raw_body: bytes, signature_header: str, secret: str) -> dict:
    parts = signature_header.split(".")
    if len(parts) != 3:
        raise ValueError("malformed signature")

    header_b64, claims_b64, signature_b64 = parts

    # 1. Verify the HMAC-SHA256 signature over "header.claims".
    expected = hmac.new(
        secret.encode(), f"{header_b64}.{claims_b64}".encode(), hashlib.sha256
    ).digest()
    if not hmac.compare_digest(expected, _b64url_decode(signature_b64)):
        raise ValueError("invalid signature")

    claims = json.loads(_b64url_decode(claims_b64))

    # 2. Bind the signature to the received body.
    body_digest = (
        base64.urlsafe_b64encode(hashlib.sha256(raw_body).digest())
        .rstrip(b"=")
        .decode()
    )
    if body_digest != claims.get("body_sha256"):
        raise ValueError("body digest mismatch")

    # 3. Check freshness and issuer.
    if claims.get("iss") != "qbee":
        raise ValueError("unexpected issuer")
    if "exp" in claims and claims["exp"] < int(time.time()):
        raise ValueError("signature expired")

    return claims

You can also verify and decode the JWT with any standard JWT library that supports HS256. If you are new to JWTs, see the jwt.io introduction. Note that most libraries only cover step 1 (signature) and step 3 (claims); you still need to check body_sha256 yourself to bind the signature to the exact payload.

Testing an integration

Use the Test action in the portal to send a sample event to your endpoint without waiting for a real device event. Test deliveries use the same envelope, headers and signature as real events, so they are a good way to validate your signature verification end to end. When a device:log event type is configured, the test uses that type (with a richer payload); otherwise it uses one of your configured event types.

Best practices

  • Verify every request. Reject deliveries whose signature or body_sha256 does not validate.
  • Respond quickly. Acknowledge with a 2xx within the 5-second timeout and process asynchronously.
  • Deduplicate on id. The same event may arrive more than once due to retries. Do not use jti for deduplication — it changes per attempt.
  • Treat events as thin. Fetch the current resource state from the API using the identifier in the payload rather than relying on the event to carry full data.
  • Keep the secret safe. Store the webhook secret securely and rotate it (by recreating the integration) if it is ever exposed.
  • Return 2xx only when accepted. Any other response causes a retry.