Skip to content

Webhooks

Vulnify POSTs a delivery for a live decision, an anomaly, or a test an administrator requested. The contract is the webhooks section of https://api.vulnify.io/openapi.json. Field tables generated from that document are on Webhook deliveries.

Verify the signature on the raw body before you parse it. The body is canonical JSON with object keys sorted. The examples use the fields and enum values from those schemas, pretty-printed so you can read them. Sign the bytes you received.

Webhook type When Vulnify sends it
decision BLOCK, REVIEW, or CRITICAL A live decision is BLOCK, REVIEW, or CRITICAL. CRITICAL can be combined with BLOCK or REVIEW. One delivery per endpoint. Sandbox events are not sent.
anomaly ANOMALY A scan opens an anomaly: VOLUME_SPIKE, NEW_ACTION, NEW_EXTERNAL_DEST, or RATE_SPIKE. One delivery per endpoint.
test TEST An administrator requests a test event. It is not a security decision.

On a decision delivery, type is the first entry of types, and it is also the X-Vulnify-Event header. Precedence is BLOCK, then REVIEW, then CRITICAL. types lists BLOCK when data.decision is BLOCK, REVIEW when it is REVIEW, then CRITICAL when data.riskLevel is CRITICAL. A delivery is one of those values, BLOCK plus CRITICAL, or REVIEW plus CRITICAL. A BLOCK and CRITICAL delivery has type BLOCK. A REVIEW and CRITICAL delivery has type REVIEW.

data.decision is the outcome recorded on the event. It does not change when a review is resolved. data.finalDecision is the effective outcome at send time: REVIEW while a review is pending, ALLOW after approval, BLOCK after denial or expiry, and equal to decision when the event has no review. To read a later outcome, poll GET /v1/events/{id}. See Reviews.

Each body has id (the delivery id), type, types, eventId, createdAt, and data. Decision eventId matches data.id. Anomaly eventId matches data.id. A test eventId is test- followed by a UUID. That value is not the delivery id.

data.decision is REVIEW and data.riskLevel is CRITICAL, so types is REVIEW then CRITICAL. Precedence puts REVIEW ahead of CRITICAL, so type is REVIEW.

{
"createdAt": "2026-10-02T20:15:00.000Z",
"data": {
"action": "EXPORT_DATA",
"agent": "SalesBot",
"decision": "REVIEW",
"finalDecision": "REVIEW",
"id": "00000000-0000-4000-8000-000000000001",
"resource": "Customer Database",
"riskLevel": "CRITICAL",
"riskScore": 95
},
"eventId": "00000000-0000-4000-8000-000000000001",
"id": "00000000-0000-4000-8000-000000000002",
"type": "REVIEW",
"types": ["REVIEW", "CRITICAL"]
}

message is an English fallback for messageCode. Render messageCode and messageParams for another language. messageParams values are strings, numbers, or null. The schema does not name those keys, so this example leaves the object empty and uses a stand-in sentence for message.

{
"createdAt": "2026-10-02T20:15:00.000Z",
"data": {
"agentId": "00000000-0000-4000-8000-000000000003",
"id": "00000000-0000-4000-8000-000000000004",
"kind": "VOLUME_SPIKE",
"message": "English fallback",
"messageCode": "anomaly.volume_spike",
"messageParams": {},
"severity": "HIGH"
},
"eventId": "00000000-0000-4000-8000-000000000004",
"id": "00000000-0000-4000-8000-000000000005",
"type": "ANOMALY",
"types": ["ANOMALY"]
}

data.message is the enum value Test event from Vulnify. eventId is test- plus a UUID. It is not id.

{
"createdAt": "2026-10-02T20:15:00.000Z",
"data": {
"message": "Test event from Vulnify",
"organizationId": "00000000-0000-4000-8000-000000000006",
"webhookId": "00000000-0000-4000-8000-000000000007"
},
"eventId": "test-00000000-0000-4000-8000-000000000009",
"id": "00000000-0000-4000-8000-000000000008",
"type": "TEST",
"types": ["TEST"]
}
Header Value
Content-Type application/json
User-Agent Vulnify-Webhooks/1.0
X-Vulnify-Signature t={unix seconds},v1={64 lowercase hex characters}
X-Vulnify-Attempt This attempt. The first send is 1.
X-Vulnify-Event Same as type.
X-Vulnify-Delivery Delivery id. Same as id.

id and X-Vulnify-Delivery stay the same on every retry. Dedupe on that id. X-Vulnify-Attempt starts at 1 and is higher on a retry of the same delivery.

Any HTTP 2xx acknowledges the delivery. 408, 429, and 5xx are retried. Any other 4xx stops retries.

X-Vulnify-Signature matches t={unix seconds},v1={hex HMAC-SHA256}.

The HMAC key is the endpoint secret as UTF-8. Use the whole whsec_ value, prefix included. The signed message is that timestamp, a dot, and the raw body: t. + raw body.

Reject the delivery when the absolute difference between your clock and t, in seconds, is greater than 300. Compare the digest in constant time.

@vulnify/sdk 0.2.3 publishes verifyWebhook(secret, rawBody, headersOrSignature). It throws WebhookVerificationError when the signature or body is rejected, and returns a payload you can switch on by type. Pass the request headers and it checks X-Vulnify-Event against type and X-Vulnify-Delivery against id. verifyWebhookSignature is the same MAC check as a boolean. vulnify 0.3.0 publishes verify_webhook_signature, construct_webhook, and construct_webhook_from_request. A bad signature raises WebhookVerificationError. The samples below use the standard library, then the published helper.

import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/**
* @param {string} secret Full whsec_ endpoint secret.
* @param {string | undefined} signatureHeader X-Vulnify-Signature.
* @param {string | Buffer} rawBody Exact request body bytes.
*/
export function verifyVulnifyWebhook(secret, signatureHeader, rawBody) {
const match = /^t=([0-9]+),v1=([0-9a-f]{64})$/.exec(signatureHeader ?? '');
if (!match) return false;
const timestamp = match[1];
const signature = match[2];
const timestampSeconds = Number(timestamp);
if (!Number.isSafeInteger(timestampSeconds)) return false;
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestampSeconds) > TOLERANCE_SECONDS) return false;
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest();
const given = Buffer.from(signature, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}

Pass the body from the HTTP server before any JSON parse. createHmac encodes a string key as UTF-8. timingSafeEqual is the constant-time compare.

verifyWebhook does that check, parses the body, and returns the delivery. BLOCK, REVIEW, and CRITICAL are decision payloads. ANOMALY and TEST are the other two.

import { WebhookVerificationError, verifyWebhook } from '@vulnify/sdk';
try {
const event = verifyWebhook(process.env.VULNIFY_WEBHOOK_SECRET ?? '', rawBody, request.headers);
if (event.type === 'BLOCK' || event.type === 'REVIEW' || event.type === 'CRITICAL') {
console.log(event.type, event.data.finalDecision, event.eventId);
}
} catch (err) {
if (err instanceof WebhookVerificationError) {
// Reject the delivery. A 4xx other than 408 or 429 stops retries.
} else {
throw err;
}
}