Webhook Endpoints
Webhooks allow you to receive real-time HTTP callbacks when events occur in VeraID. Each webhook is configured with a target URL and a list of event types to subscribe to. All webhook payloads are signed with HMAC-SHA256 for verification.
These routes take a signed-in session (the dashboard): any member may list and read webhooks, and only an organization admin may create, change, test or delete them. API keys are not accepted.
List Webhooks
GET /api/v1/webhooksExample Response
{ "data": [ { "id": "c3f1e2d4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "name": "Production Event Handler", "url": "https://api.company.com/webhooks/veraid", "events": ["identity.created", "credential.revoked", "alert.triggered"], "isActive": true, "lastTriggeredAt": "2026-03-19T09:30:00.000Z", "failureCount": 0, "createdAt": "2026-01-15T10:00:00.000Z", "updatedAt": "2026-03-01T14:30:00.000Z" } ]}Create Webhook
POST /api/v1/webhooksRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 2 to 100 characters |
url | string | Yes | Where deliveries are sent: an HTTPS URL to a public address |
events | string[] | Yes | At least one of the event types |
Example Request
{ "name": "Security Event Handler", "url": "https://api.company.com/webhooks/veraid-security", "events": ["identity.suspended", "credential.revoked", "anomaly.detected", "alert.triggered"]}Example Response (201)
{ "id": "d4e5f6a7-8b9c-4d0e-9f1a-2b3c4d5e6f70", "organizationId": "8f14e45f-ceea-4c6c-9e7b-1a2b3c4d5e6f", "name": "Security Event Handler", "url": "https://api.company.com/webhooks/veraid-security", "events": ["identity.suspended", "credential.revoked", "anomaly.detected", "alert.triggered"], "secret": "9f2c...64 hex characters...a1", "isActive": true, "failureCount": 0, "lastTriggeredAt": null, "createdAt": "2026-03-19T10:00:00.000Z", "updatedAt": "2026-03-19T10:00:00.000Z"}Get Webhook
GET /api/v1/webhooks/{id}Answers the webhook as in the list (id, name, url, events, isActive, lastTriggeredAt, failureCount, createdAt, updatedAt), never its secret.
Update Webhook
PUT /api/v1/webhooks/{id}Takes any of name, url and events, as on create, and answers the updated webhook without its secret. A withdrawn event name (credential.used) is dropped rather than refused, so a list read from an older client can be sent back unchanged; if nothing else is left, the update fails validation.
{ "events": ["identity.created", "identity.suspended", "credential.revoked", "credential.expired", "anomaly.detected", "alert.triggered"]}Delete Webhook
DELETE /api/v1/webhooks/{id}No further events are delivered to the endpoint. Answers { "success": true }.
Test Webhook
Sends a test delivery (event identity.created, with data.test: true) to check connectivity and signature verification.
POST /api/v1/webhooks/{id}/testExample Response
{ "success": true, "statusCode": 200, "duration": 120}On failure, success is false and error says why. A test delivery is signed like any other, but carries X-Webhook-Id (the webhook’s id) instead of X-Webhook-Delivery-Id.
Delivery History
GET /api/v1/webhooks/{id}/deliveries lists the webhook’s delivery attempts, each with its status code and error.
Event Types
Subscribe to any combination of the following event types:
Identity Events
| Event | Description |
|---|---|
identity.created | A new identity was created |
identity.updated | An identity’s configuration was modified |
identity.suspended | An identity was suspended |
identity.revoked | An identity was permanently revoked or deleted |
Credential Events
| Event | Description |
|---|---|
credential.created | A new credential was issued |
credential.expired | A credential reached its expiration date |
credential.revoked | A credential was manually revoked or rotated |
credential.used is no longer accepted in a new subscription. It was never sent: every API call uses a credential, so it would be one delivery per call, and each use is already recorded in the audit log as credential.verify. A webhook saved with it keeps it, where it matches nothing; it is left out of the webhook as answered, and dropped from an update that sends it back.
Security Events
| Event | Description |
|---|---|
policy.violated | A policy refused a request: a DENY policy matched, or an ALLOW policy’s condition failed |
anomaly.detected | Anomalous behavior was detected by the analysis engine |
alert.triggered | A security alert was raised, or an open alert escalated to a higher severity |
Webhook Payload Format
Every delivery is a POST with a JSON body in this envelope:
{ "event": "credential.created", "timestamp": "2026-03-19T10:00:00.000Z", "organizationId": "8f14e45f-ceea-4c6c-9e7b-1a2b3c4d5e6f", "data": { "credential": { "id": "cred_xyz789", "name": "deploy-key", "type": "API_KEY", "identityId": "id_def456" } }}data depends on the event. No payload carries a secret value.
alert.triggered
Sent when a security alert is raised, and again when an open alert escalates to a higher severity (escalation is present then). Repeats of an open alert at the same severity are not sent.
{ "event": "alert.triggered", "timestamp": "2026-10-15T09:00:00.000Z", "organizationId": "8f14e45f-ceea-4c6c-9e7b-1a2b3c4d5e6f", "data": { "alert": { "id": "a1b2c3d4-0000-4000-8000-000000000001", "type": "AI_AGENT_QUOTA_EXCEEDED", "severity": "CRITICAL", "status": "OPEN", "identityId": "id_def456", "title": "Budget exceeded: support-bot (daily)", "createdAt": "2026-10-15T08:41:12.000Z" }, "escalation": { "previousSeverity": "HIGH" } }}The payload is a reference to the alert: it never includes the alert’s message or details, which can hold a source IP, a commit author or a preview of a leaked secret. The full alert is on VeraID’s Alerts page.
policy.violated
Sent when policy enforcement refuses a request because of a policy: a DENY policy matched it (condition is null), or an ALLOW policy applied to it but one of its conditions failed (an IP range, a time window, a rate limit). A request refused only because no policy applies to it is not a violation and is not sent. At most one is sent a minute for the same policy, caller and action, so a client retrying a refused call sends one, not one per attempt.
{ "event": "policy.violated", "timestamp": "2026-10-15T09:00:00.000Z", "organizationId": "8f14e45f-ceea-4c6c-9e7b-1a2b3c4d5e6f", "data": { "policy": { "id": "pol_abc123", "name": "Office network only", "effect": "ALLOW" }, "condition": { "type": "ip_range", "reason": "IP 203.0.113.42 not in allowed ranges" }, "action": "credential.read", "resource": { "type": "credential", "id": null }, "actor": { "type": "API_KEY", "userId": null, "identityId": "id_def456", "credentialId": "cred_xyz789" } }}| Field | |
|---|---|
policy | The policy that refused the request. effect is DENY for a DENY policy that matched, ALLOW for an ALLOW policy whose condition failed |
condition | null for a DENY policy. Otherwise the failed condition: type as in the policy (ip_range, time_window, rate_limit, geo_fencing, require_mfa, …), and reason, the one the refused caller sees in its 403’s details.conditions, or null when the condition gave none |
action | The policy action that was refused, such as credential.read or identity.update |
resource | type, and id when the route names one (null for a list or a create) |
actor | type (USER or API_KEY), with the user’s id, or the identity’s and credential’s ids. The credential is named by id, never its value |
type, action and resource.type are open sets: new values can appear as enforcement reaches more routes and conditions.
Signature Verification
Every delivery is signed with HMAC-SHA256 using the webhook’s signing secret. Verify the signature to make sure the payload came from VeraID and was not altered.
Headers
| Header | Description |
|---|---|
X-Webhook-Signature | sha256= followed by the hex HMAC-SHA256 of {timestamp}.{body} |
X-Webhook-Timestamp | Unix time, in seconds, when the delivery was signed |
X-Webhook-Event | The event name, as in the body’s event |
X-Webhook-Delivery-Id | The delivery’s id, the same on every retry of it: use it to ignore duplicates |
Verification Process
- Concatenate the timestamp header and the raw request body:
{timestamp}.{body}. - Compute the HMAC-SHA256 of that string with your webhook’s signing secret, as hex.
- Compare it with the
X-Webhook-Signatureheader, after itssha256=prefix.
import crypto from 'crypto';
function verifyWebhookSignature( rawBody: string, signatureHeader: string | undefined, timestamp: string | undefined, secret: string): boolean { if (!signatureHeader || !timestamp) return false; const expected = Buffer.from( 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') ); const received = Buffer.from(signatureHeader); return received.length === expected.length && crypto.timingSafeEqual(received, expected);}The SDKs’ helpers do this, with the timestamp check: verifyWebhookSignature(rawBody, signature, timestamp, secret) in TypeScript and Python (verify_webhook_signature), and VerifyWebhookSignature(payload, signature, timestamp, secret) in Go.
Retry Policy
A delivery is attempted up to 5 times. After a failed attempt, the next waits:
| After attempt | Delay |
|---|---|
| 1st | 30 seconds |
| 2nd | 2 minutes |
| 3rd | 8 minutes |
| 4th | 32 minutes |
A delivery counts as failed if:
- The endpoint returns a non-2xx HTTP status code.
- The endpoint does not respond within 10 seconds.
- A network error occurs (DNS resolution failure, connection refused, etc.).
A delivery to a URL that is not allowed (an internal or private address) fails at once and is not retried. After the fifth failed attempt, a delivery is no longer retried. Every attempt, with its status code and error, is listed in the webhook’s delivery history (GET /api/v1/webhooks/{id}/deliveries).