A webhook tells your server when a scan ends, so you don't have to poll. ValiqAI sends a signed POST to your address with what happened. Paths are under https://www.valiqai.com/api/v1; webhooks can also be managed in the app under Integrations › Webhooks.

Events
| Event | Sent when |
|---|---|
scan.completed | A scan finished, whatever it found. data.status is passed, failed, warning or partial. |
scan.failed | A scan couldn't finish. data.error says why. |
scan.cancelled | A scan was stopped, from the app, the API or the CLI. |
scan.started is accepted when you subscribe, for older integrations, but isn't sent.
A webhook gets events for the scans of its workspace: a team webhook, for the team's scans while the person who added it is on the team; a personal webhook, for every scan you start.
POST /webhooks
Adds a webhook. Scope webhooks.
{ "url": "https://example.com/hooks/valiqai", "events": ["scan.completed", "scan.failed"] }events defaults to ["scan.completed"]. A team key's webhooks belong to its team; a personal key can add a team webhook with organizationId if you're an owner or admin there.
{
"webhook": {
"id": "cmw1…",
"url": "https://example.com/hooks/valiqai",
"events": ["scan.completed", "scan.failed"],
"secret": "9f2c…",
"active": true
},
"message": "Webhook created successfully. Save the secret for signature verification."
}The secret is shown only here. Store it, and use it to check each delivery's signature.
| Status | error |
|---|---|
| 400 | Webhook URL is required |
| 400 | Invalid URL format, or Invalid URL for a private or internal address |
| 400 | Invalid events: <list>. Valid events: scan.completed, scan.failed, scan.cancelled, scan.started |
| 403 | This is a team key; it can only add webhooks to its own team |
| 403 | Only team owners and admins can add a team webhook |
| 429 | Maximum 10 webhooks per user (per team, for a team key) |
GET /webhooks
Lists webhooks, oldest first, with how deliveries are going. Scope webhooks.
{
"webhooks": [
{
"id": "cmw1…",
"url": "https://example.com/hooks/valiqai",
"events": ["scan.completed"],
"active": true,
"organizationId": "org_8f2…",
"createdAt": "2026-10-20T12:00:00.000Z",
"lastDeliveryAt": "2026-10-20T13:15:16.000Z",
"lastStatus": 200,
"failureCount": 0
}
]
}DELETE /webhooks
Removes one: DELETE /webhooks?id=<id>. Scope webhooks. Answers { "success": true }, or 404 Webhook not found.
Changing a webhook's events, turning it off and rotating its secret are in the app, under Integrations › Webhooks.
Deliveries
Each delivery is a POST with a JSON body:
{
"id": "7f1d2c9e-0a3b-4f1e-9c2d-5b6a7e8f9012",
"event": "scan.completed",
"timestamp": "2026-10-20T13:15:16.000Z",
"organizationId": "org_8f2…",
"data": {
"id": "cmh2x8k1p0",
"event": "scan.completed",
"status": "failed",
"url": "https://staging.example.com",
"score": 64,
"findingsCount": 3,
"pollUrl": "/api/v1/scans/cmh2x8k1p0"
}
}organizationIdis the workspace the scan ran in.scoreandfindingsCountcome withscan.completed;errorcomes withscan.failedandscan.cancelled.findingsCountcounts blockers, high and medium findings. For the problems themselves,GETthe scan.idis the delivery's id, the same on every retry: use it to ignore a repeat.
| Header | What it is |
|---|---|
X-ValiqAI-Event | The event, like scan.completed. |
X-ValiqAI-Delivery-ID | The delivery's id, the same as the body's id. |
X-ValiqAI-Delivery-Attempt | 1, 2 or 3. |
X-ValiqAI-Signature | t=<unix seconds>,v1=<signature>. See below. |
User-Agent | ValiqAI-Webhook/1.0 |
Retries. A delivery succeeds when your server answers 2xx within 10 seconds. Otherwise it's tried again 30 seconds later, then 2 minutes after that: three attempts in all. Redirects aren't followed, so answer from the address itself. After 20 deliveries in a row fail, the webhook is turned off; turn it back on in the app.
Check the signature
The signature is an HMAC-SHA256, keyed with your webhook's secret, of the timestamp, a dot, and the raw request body. Check it on the raw body, before parsing it, and refuse a timestamp more than 5 minutes old:
import crypto from 'node:crypto';
/** True when a delivery is from ValiqAI. `rawBody` is the request body as received. */
export function verifyValiqaiSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Each retry is signed again with a new timestamp, over the same body.
Per-scan callbacks
POST /scans also takes a webhook address for that one scan. When the scan ends, the address gets a POST with the data object above on its own, with the same headers and retries. It's signed only when the address is also one of your active webhooks; otherwise it's sent unsigned, so prefer a webhook.