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
POSTrequests with aContent-Type: application/jsonbody. - Every request is signed with a per-integration secret using a JWT in the
Qbee-Signatureheader (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:logevents 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
payloadvalues are always strings. All device events reference the device through thedevice_idfield, 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:onlineanddevice:offlineare 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 isERR. - 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:
- Verify the JWT signature — recompute
HMAC-SHA256(header + "." + claims)with your webhook secret and compare it (in constant time) to the third segment. - 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_sha256claim. - (Recommended) Check freshness and identity — reject the request if
expis in the past, and confirmissisqbeeandsubmatches 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_sha256does 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 usejtifor 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.