Documentation
Webhooks
Receive signed HTTP notifications when assets, scans, vulnerabilities, compliance controls, exports and flows change, and verify every delivery.
Overview
A webhook sends an HTTP POST to your endpoint each time a subscribed event happens in DeviceQuest, for example a new asset, a finished scan or a new vulnerability. Every delivery is signed with your endpoint’s secret, so your receiver can prove it came from DeviceQuest and wasn’t changed on the way. Failed deliveries are retried automatically.
In the app, you need the webhooks:read permission to see webhook endpoints and their deliveries, and webhooks:write to create, edit, test, rotate and delete them.
Manage webhook endpoints
You manage endpoints on the Developer page, in the Webhooks section. The same operations are available in GraphQL (webhooks, createWebhook, updateWebhook, rotateWebhookSecret, testWebhook, deleteWebhook, webhookDeliveries).
Add an endpoint
- On the Developer page, in Webhooks, click Add endpoint.
- In Name, enter a name you will recognise, for example
SIEM ingest. - In URL, enter your receiver’s address, for example
https://hooks.example.com/devicequest. See Endpoint URL rules. - Under Event types, select one or more events. See Event types.
- Leave Active selected to start deliveries straight away.
- Click Save.
- The Webhook created dialog shows the signing secret once. Click Copy secret, store it in your receiver’s secret store, then click Done. DeviceQuest can’t show the secret again; the endpoint details show only a short Secret prefix fingerprint.
Fields
| Field | Description | Rules |
|---|---|---|
| Name | Display name of the endpoint. | Required. |
| URL | Where DeviceQuest sends deliveries. | Required. Must start with http:// or https://. See Endpoint URL rules. |
| Event types | The events this endpoint receives. | Select at least one. |
| Active | Enabled endpoints receive deliveries; disabled endpoints receive none. | Selected by default. |
Each endpoint waits up to 10 seconds for your response.
Endpoint details and actions
Click an endpoint in the list to open its details: URL, Secret prefix, Event types, Timeout, Created and Updated, plus these actions:
- Test: sends a test delivery now. See Send a test delivery.
- Edit: changes the name, URL, event types or Active setting.
- Rotate secret: creates a new signing secret. Confirm with Rotate secret; the Webhook secret rotated dialog shows the new secret once. The old secret stops working immediately, and every delivery from then on (including retries of earlier events) is signed with the new secret. Update your receiver right after rotating.
- Delete: removes the endpoint after you confirm with Delete webhook. No further events are delivered to it.
Recent deliveries lists the latest deliveries to the endpoint with their event type, status, number of attempts and payload. Use the All, Enabled and Disabled chips above the list to filter endpoints.
Endpoint URL rules
DeviceQuest checks the URL when you save an endpoint and again before every delivery:
- The scheme is
httporhttps. Usehttpsin production. - The URL has no user name or password (
https://user:pass@host/is refused). Authenticate deliveries by verifying the signature. - The host name must resolve in DNS.
- By default, DeviceQuest delivers only to public internet addresses. It refuses loopback (
127.0.0.0/8,::1), private (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7), link-local (169.254.0.0/16,fe80::/10) and unspecified addresses, and the cloud metadata address169.254.169.254. To deliver to an on-premises receiver on a private address, ask your DeviceQuest administrator to allow that destination. - Redirects are followed only to addresses that pass the same checks.
What a delivery looks like
Each delivery is an HTTP POST with a JSON body. The body is the event payload (see Event types).
| Header | Example | Meaning |
|---|---|---|
Content-Type | application/json | The body is JSON. |
X-DeviceQuest-Event | NEW_ASSET | The event type. |
X-DeviceQuest-Event-ID | 3b0e6f5c-2a1d-4c7e-9f8a-6d5b4c3a2e1f | Unique ID of the event. It stays the same on every retry, so use it to ignore duplicates. |
X-DeviceQuest-Timestamp | 2026-10-13T06:00:00Z | When this attempt was sent, in UTC (RFC 3339). |
X-DeviceQuest-Signature | 213cfccc…a58a9a | HMAC-SHA256 signature of the attempt, as 64 lowercase hex characters. |
Example request:
POST /devicequest HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
X-DeviceQuest-Event: NEW_ASSET
X-DeviceQuest-Event-ID: 3b0e6f5c-2a1d-4c7e-9f8a-6d5b4c3a2e1f
X-DeviceQuest-Timestamp: 2026-10-13T06:00:00Z
X-DeviceQuest-Signature: 213cfcccf1997fafb8cff8636d8bf53a5046681b86a9231aa4753b3bd6a58a9a
{"asset_id": "6f1c2b9e-4d3a-4f8e-9b7a-2c5d8e1f0a3b", "tenant_id": "00000000-0000-0000-0000-000000000001"}
Respond with any 2xx status (for example 200 or 204) within 10 seconds to acknowledge the delivery. Do slow work after you respond, for example on a queue.
Verifying signatures
Verify every delivery before you trust it. The signature is:
X-DeviceQuest-Signature = hex( HMAC-SHA256( key = secret, message = raw body + X-DeviceQuest-Timestamp ) )
- key: your endpoint’s signing secret, used exactly as shown when you created or rotated it (a 64-character string). Use the string’s bytes; don’t hex-decode it.
- message: the raw request body bytes, exactly as received, immediately followed by the
X-DeviceQuest-Timestampheader value. There is no separator. - output: lowercase hexadecimal, with no prefix such as
sha256=.
To verify a delivery:
- Read the raw body before any JSON parsing. Re-serialising the JSON changes spacing and key order, and the signature will no longer match.
- Compute the HMAC as above and compare it to
X-DeviceQuest-Signaturewith a constant-time comparison. - Check that
X-DeviceQuest-Timestampis recent, for example within 5 minutes of your clock, to block replayed requests. - Reject the request with
401if any check fails. UseX-DeviceQuest-Event-IDto ignore events you have already processed.
You can check your implementation with this test vector:
| Input | Value |
|---|---|
| Secret | 3f9a1c7e5b2d8f4a6c0e9b1d7a3f5c2e8b4d6a0f1c3e5b7d9f2a4c6e8b0d1f3a |
| Timestamp | 2026-10-13T06:00:00Z |
| Raw body | {"asset_id": "6f1c2b9e-4d3a-4f8e-9b7a-2c5d8e1f0a3b", "tenant_id": "00000000-0000-0000-0000-000000000001"} |
| Expected signature | 213cfcccf1997fafb8cff8636d8bf53a5046681b86a9231aa4753b3bd6a58a9a |
Node.js (Express)
const crypto = require('node:crypto');
const express = require('express');
const SECRET = process.env.DEVICEQUEST_WEBHOOK_SECRET;
const TOLERANCE_MS = 5 * 60 * 1000; // reject deliveries older than 5 minutes
function verifySignature(secret, rawBody, timestamp, signature) {
if (!timestamp || !signature) return false;
const sentAt = Date.parse(timestamp);
if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > TOLERANCE_MS) return false;
// Sign exactly the bytes you received, followed by the timestamp header.
const expected = crypto
.createHmac('sha256', secret)
.update(Buffer.concat([rawBody, Buffer.from(timestamp, 'utf8')]))
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw keeps the body as a Buffer, exactly as DeviceQuest sent it.
app.post('/webhooks/devicequest', express.raw({ type: 'application/json' }), (req, res) => {
const valid = verifySignature(
SECRET,
req.body,
req.get('X-DeviceQuest-Timestamp'),
req.get('X-DeviceQuest-Signature'),
);
if (!valid) return res.status(401).send('invalid signature');
const eventType = req.get('X-DeviceQuest-Event');
const eventId = req.get('X-DeviceQuest-Event-ID');
const payload = JSON.parse(req.body.toString('utf8'));
console.log(eventType, eventId, payload);
res.sendStatus(204);
});
app.listen(3000);
Python (Flask)
import hashlib
import hmac
import os
from datetime import datetime, timezone
from flask import Flask, request
SECRET = os.environ["DEVICEQUEST_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300 # reject deliveries older than 5 minutes
def verify_signature(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
if not timestamp or not signature:
return False
try:
sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError:
return False
if sent_at.tzinfo is None:
return False
if abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > TOLERANCE_SECONDS:
return False
# Sign exactly the bytes you received, followed by the timestamp header.
expected = hmac.new(
secret.encode("utf-8"),
raw_body + timestamp.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
app = Flask(__name__)
@app.post("/webhooks/devicequest")
def devicequest_webhook():
raw_body = request.get_data() # raw bytes, before any JSON parsing
if not verify_signature(
SECRET,
raw_body,
request.headers.get("X-DeviceQuest-Timestamp", ""),
request.headers.get("X-DeviceQuest-Signature", ""),
):
return "invalid signature", 401
event_type = request.headers.get("X-DeviceQuest-Event")
event_id = request.headers.get("X-DeviceQuest-Event-ID")
payload = request.get_json()
print(event_type, event_id, payload)
return "", 204
Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
)
const tolerance = 5 * time.Minute // reject deliveries older than 5 minutes
func verifySignature(secret string, rawBody []byte, timestamp, signature string) bool {
if timestamp == "" || signature == "" {
return false
}
sentAt, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return false
}
if age := time.Since(sentAt); age > tolerance || age < -tolerance {
return false
}
// Sign exactly the bytes you received, followed by the timestamp header.
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
mac.Write([]byte(timestamp))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
if !verifySignature(secret, rawBody,
r.Header.Get("X-DeviceQuest-Timestamp"),
r.Header.Get("X-DeviceQuest-Signature")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var payload map[string]any
if err := json.Unmarshal(rawBody, &payload); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
log.Println(r.Header.Get("X-DeviceQuest-Event"), r.Header.Get("X-DeviceQuest-Event-ID"), payload)
w.WriteHeader(http.StatusNoContent)
}
}
func main() {
http.Handle("POST /webhooks/devicequest", webhookHandler(os.Getenv("DEVICEQUEST_WEBHOOK_SECRET")))
log.Fatal(http.ListenAndServe(":3000", nil))
}
Retries and failed deliveries
A delivery succeeds when your endpoint answers with any 2xx status. Any other status, a timeout (10 seconds) or a connection error counts as a failed attempt, and DeviceQuest tries again:
- Up to 10 attempts in total.
- Exponential backoff: after failed attempt n, DeviceQuest waits 2 s × 2n−1, plus up to 25 % random jitter, capped at 1 hour.
- After the 10th failed attempt the delivery is dead (shown as Dead letter) and is not retried again.
| Attempt | Sent after the previous failure (before jitter) |
|---|---|
| 1 | Immediately, when the event happens |
| 2 | 2 seconds |
| 3 | 4 seconds |
| 4 | 8 seconds |
| 5 | 16 seconds |
| 6 | 32 seconds |
| 7 | about 1 minute |
| 8 | about 2 minutes |
| 9 | about 4 minutes |
| 10 | about 8.5 minutes |
Each endpoint has its own delivery record and retry schedule, so a failing endpoint doesn’t stop deliveries to your other endpoints. Deliveries can arrive more than once (for example, if your endpoint processed a request but the response was lost), and in a different order than the events happened. Use X-DeviceQuest-Event-ID to de-duplicate, and the timestamps in the payload to order events.
Delivery statuses
| Status | Meaning | Next possible status |
|---|---|---|
| Pending | Waiting for its first attempt. | Processing |
| Processing | An attempt is being sent. | Delivered, Failed, Dead letter |
| Delivered | Your endpoint answered 2xx. | None |
| Failed | The last attempt failed; another attempt is scheduled. Recent deliveries shows the attempt count and the last error. | Processing |
| Dead letter | All 10 attempts failed. | None |
Send a test delivery
Use a test delivery to check your endpoint’s address, TLS and signature verification before real events arrive.
- On the Developer page, in Webhooks, click Test on the endpoint’s row, or open the endpoint and click Test in its details.
- DeviceQuest sends one signed
POSTstraight away and waits for the answer. A2xxanswer shows Test request delivered successfully to “”. Any other result shows Failed to send test event to “”.
A test delivery is signed exactly like a real one, with X-DeviceQuest-Event: webhook.test. It has no X-DeviceQuest-Event-ID and is not retried. The body is:
{"test":true,"webhookId":"9d2f4b6a-1c3e-4a5b-8d7f-0e1a2b3c4d5e","triggeredAt":"2026-10-13T06:00:00Z"}
Your receiver should accept webhook.test and answer 2xx without processing it as a real event.
Event types
Subscribe to any of these 10 event types. The In the app column shows the label in the Event types list.
| Event type | In the app | Sent when |
|---|---|---|
NEW_ASSET | New asset | A new asset is discovered (by a scan, an agent, a cloud connector or an import). |
UPDATED_ASSET | Updated asset | An asset changes: higher-confidence identification, duplicates merged into it, or a custom field set or cleared. |
DELETED_ASSET | Deleted asset | An asset is deleted, or removed because it was merged into another asset. |
NEW_SOURCE | New source | A data source (scanner, agent, import file or connector) sends its first observations. |
EXPORT_COMPLETED | Export completed | An export finishes: assets CSV, report export, compliance report export or Statement of Applicability. |
UNLINK | Asset unlinked | Two assets that were identified as one are separated again. |
SCAN_COMPLETED | Scan completed | A scan job finishes. |
VULNERABILITY_FOUND | Vulnerability found | A new vulnerability finding is matched to an asset. |
COMPLIANCE_UPDATED | Compliance updated | A compliance control’s evaluated status changes, for example from pass to fail. |
FLOW_TRIGGERED | Flow triggered | An automation flow starts running. |
Every payload contains tenant_id. Timestamps are UTC in RFC 3339 format. New fields can be added to payloads over time, so ignore fields you don’t recognise.
NEW_ASSET
{
"asset_id": "6f1c2b9e-4d3a-4f8e-9b7a-2c5d8e1f0a3b",
"asset_name": "web-01.example.local",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"asset_type": "device",
"source": "discovery",
"confidence": 0.85,
"first_seen": "2026-10-13T06:00:00Z"
}
source is discovery, agent_scan, cloud_provider, passive or manual. Assets created during a scan can carry only asset_id, asset_name and tenant_id.
UPDATED_ASSET
{
"asset_id": "6f1c2b9e-4d3a-4f8e-9b7a-2c5d8e1f0a3b",
"asset_name": "web-01.example.local",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"changed_fields": ["custom_field:Cost centre"],
"custom_field": {
"id": "c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Cost centre",
"value": "CC-1042",
"operation": "set"
},
"updated_at": "2026-10-13T06:05:00Z"
}
changed_fields names what changed: confidence (a better identification), merged (duplicates were merged into this asset) or custom_field:<name>. Identification changes also carry source; custom_field is present only for custom field changes. Assets updated by a scan can carry only asset_id, asset_name and tenant_id.
DELETED_ASSET
{
"asset_id": "0d9c8b7a-6f5e-4d3c-2b1a-0f9e8d7c6b5a",
"asset_name": "web-01-old",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"reason": "merged",
"deleted_at": "2026-10-13T06:10:00Z"
}
reason is merged when the asset was merged into another one. Assets deleted by a user carry asset_id, asset_name and tenant_id.
NEW_SOURCE
{
"source_id": "7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"source_type": "ssh",
"name": "discovery:ssh:10.20.0.15",
"connected_at": "2026-10-13T06:00:00Z"
}
EXPORT_COMPLETED
{
"export_id": "a1b2c3d4-e5f6-4789-8abc-def012345678",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"kind": "report",
"format": "pdf",
"filename": "report-4f6a2b8c-3d5e-4f6a-9b8c-7d6e5f4a3b2c-20261013T060000Z.pdf",
"resource_id": "4f6a2b8c-3d5e-4f6a-9b8c-7d6e5f4a3b2c",
"completed_at": "2026-10-13T06:00:00Z"
}
kind is assets, report, compliance_report or statement_of_applicability. row_count is included when the export has a row count; filename and resource_id (the report, compliance report or framework) when they apply.
UNLINK
Sent when observations are split off an asset with the GraphQL mutation unlinkAssetObservations(assetId, observationIds). Each observation moves to a new asset of its own, and one UNLINK event is sent per new asset: asset_id_a is the original asset, asset_id_b the new one.
{
"asset_id_a": "6f1c2b9e-4d3a-4f8e-9b7a-2c5d8e1f0a3b",
"asset_id_b": "0d9c8b7a-6f5e-4d3c-2b1a-0f9e8d7c6b5a",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"reason": "manual unlink",
"unlinked_at": "2026-10-13T06:00:00Z"
}
SCAN_COMPLETED
{
"scan_id": "2d0c8f4e-6b1a-4e7d-9c3f-5a8b7e6d4c21",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"scan_type": "sweep",
"target": "10.20.0.0/24",
"asset_count": 42,
"duration_ms": 183250,
"completed_at": "2026-10-13T06:03:05Z"
}
VULNERABILITY_FOUND
{
"vulnerability_id": "1b7e0c3d-9a52-4f1e-8c6d-3e2f1a0b9c87",
"asset_id": "6f1c2b9e-4d3a-4f8e-9b7a-2c5d8e1f0a3b",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"cve_id": "CVE-2024-6387",
"severity": "high",
"match_type": "exact",
"confidence": 0.95,
"found_at": "2026-10-13T06:00:00Z"
}
vulnerability_id is the finding ID used by GET /api/v1/vulnerabilities/{id}. match_type is exact, fuzzy or heuristic.
COMPLIANCE_UPDATED
{
"compliance_id": "5c4b3a29-1807-4f6e-9d8c-7b6a59483726",
"asset_id": "",
"asset_ids": ["6f1c2b9e-4d3a-4f8e-9b7a-2c5d8e1f0a3b"],
"tenant_id": "00000000-0000-0000-0000-000000000001",
"framework": "ISO 27001",
"control_id": "A.8.8",
"status": "fail",
"previous_status": "pass",
"updated_at": "2026-10-13T06:00:00Z"
}
Controls are evaluated across your whole organisation, so asset_id is empty and asset_ids lists the assets that caused the status. status and previous_status are pass, fail, warning, not_applicable or not_assessed.
FLOW_TRIGGERED
{
"flow_id": "646cfbc3-daee-4f3c-8650-3b99d31a8dde",
"execution_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"event_type": "VULNERABILITY_FOUND",
"triggered_at": "2026-10-13T06:00:01Z"
}
event_type is what started the flow: the triggering event’s type for event-driven flows, otherwise the trigger kind, for example manual, schedule or webhook. A flow started by a FLOW_TRIGGERED event doesn’t emit another FLOW_TRIGGERED event, so flows can’t trigger each other in a loop.