Skip to content

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

  1. On the Developer page, in Webhooks, click Add endpoint.
  2. In Name, enter a name you will recognise, for example SIEM ingest.
  3. In URL, enter your receiver’s address, for example https://hooks.example.com/devicequest. See Endpoint URL rules.
  4. Under Event types, select one or more events. See Event types.
  5. Leave Active selected to start deliveries straight away.
  6. Click Save.
  7. 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

FieldDescriptionRules
NameDisplay name of the endpoint.Required.
URLWhere DeviceQuest sends deliveries.Required. Must start with http:// or https://. See Endpoint URL rules.
Event typesThe events this endpoint receives.Select at least one.
ActiveEnabled 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 http or https. Use https in 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 address 169.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).

HeaderExampleMeaning
Content-Typeapplication/jsonThe body is JSON.
X-DeviceQuest-EventNEW_ASSETThe event type.
X-DeviceQuest-Event-ID3b0e6f5c-2a1d-4c7e-9f8a-6d5b4c3a2e1fUnique ID of the event. It stays the same on every retry, so use it to ignore duplicates.
X-DeviceQuest-Timestamp2026-10-13T06:00:00ZWhen this attempt was sent, in UTC (RFC 3339).
X-DeviceQuest-Signature213cfccc…a58a9aHMAC-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-Timestamp header value. There is no separator.
  • output: lowercase hexadecimal, with no prefix such as sha256=.

To verify a delivery:

  1. Read the raw body before any JSON parsing. Re-serialising the JSON changes spacing and key order, and the signature will no longer match.
  2. Compute the HMAC as above and compare it to X-DeviceQuest-Signature with a constant-time comparison.
  3. Check that X-DeviceQuest-Timestamp is recent, for example within 5 minutes of your clock, to block replayed requests.
  4. Reject the request with 401 if any check fails. Use X-DeviceQuest-Event-ID to ignore events you have already processed.

You can check your implementation with this test vector:

InputValue
Secret3f9a1c7e5b2d8f4a6c0e9b1d7a3f5c2e8b4d6a0f1c3e5b7d9f2a4c6e8b0d1f3a
Timestamp2026-10-13T06:00:00Z
Raw body{"asset_id": "6f1c2b9e-4d3a-4f8e-9b7a-2c5d8e1f0a3b", "tenant_id": "00000000-0000-0000-0000-000000000001"}
Expected signature213cfcccf1997fafb8cff8636d8bf53a5046681b86a9231aa4753b3bd6a58a9a

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.
AttemptSent after the previous failure (before jitter)
1Immediately, when the event happens
22 seconds
34 seconds
48 seconds
516 seconds
632 seconds
7about 1 minute
8about 2 minutes
9about 4 minutes
10about 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

StatusMeaningNext possible status
PendingWaiting for its first attempt.Processing
ProcessingAn attempt is being sent.Delivered, Failed, Dead letter
DeliveredYour endpoint answered 2xx.None
FailedThe last attempt failed; another attempt is scheduled. Recent deliveries shows the attempt count and the last error.Processing
Dead letterAll 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.

  1. On the Developer page, in Webhooks, click Test on the endpoint’s row, or open the endpoint and click Test in its details.
  2. DeviceQuest sends one signed POST straight away and waits for the answer. A 2xx answer 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 typeIn the appSent when
NEW_ASSETNew assetA new asset is discovered (by a scan, an agent, a cloud connector or an import).
UPDATED_ASSETUpdated assetAn asset changes: higher-confidence identification, duplicates merged into it, or a custom field set or cleared.
DELETED_ASSETDeleted assetAn asset is deleted, or removed because it was merged into another asset.
NEW_SOURCENew sourceA data source (scanner, agent, import file or connector) sends its first observations.
EXPORT_COMPLETEDExport completedAn export finishes: assets CSV, report export, compliance report export or Statement of Applicability.
UNLINKAsset unlinkedTwo assets that were identified as one are separated again.
SCAN_COMPLETEDScan completedA scan job finishes.
VULNERABILITY_FOUNDVulnerability foundA new vulnerability finding is matched to an asset.
COMPLIANCE_UPDATEDCompliance updatedA compliance control’s evaluated status changes, for example from pass to fail.
FLOW_TRIGGEREDFlow triggeredAn 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.

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.