Public API for managing webhook subscriptions. Use these endpoints to create, retrieve, update, and delete webhook subscriptions that deliver event notifications to your own HTTP endpoints.
- Base URL:
https://api.verbex.ai/v1/public/webhook/subscription
- Auth: Bearer token (API key) in the
Authorization header
- Content type:
application/json (for POST and PUT)
Response convention — null/unset fields are omitted. Optional fields that have no value (e.g. headers, description, agent_ids, workspace_ids) are left out of the response body entirely rather than returned as null. Treat a missing key as “not set”. The … | null notation in the tables below indicates such a field; in practice the key is simply absent when unset.
Authentication
Every request must include your API key as a Bearer token:
Authorization: Bearer <YOUR_API_KEY>
Security note: Your API key is a credential. Never commit it to source control, share it in tickets, or expose it client-side. If a key is ever exposed, rotate it immediately.
Endpoints
1. Create a subscription
Description: Registers a new webhook subscription so Verbex delivers selected event notifications to your endpoint as HTTP POST requests. Optionally scope it to specific agents or workspaces, filter the events it receives, and enable HMAC-signed delivery. Returns the created subscription — including a one-time signing secret when enable_signed_webhooks is true. Use that secret to verify the authenticity of incoming deliveries: Verbex signs each request (HMAC) so your endpoint can confirm it genuinely originated from Verbex and reject forged or tampered payloads. The secret (prefixed whsec_) is returned only once at creation, so store it securely; if it’s lost or leaked, rotate it via the rotate-secret endpoint.
Method: POST /v1/public/webhook/subscription
Headers
| Header | Required | Value |
|---|
Authorization | Yes | Bearer <YOUR_API_KEY> |
Content-Type | Yes | application/json |
Request Body
| Field | Type | Required | Description |
|---|
name | string | Yes | Display name for the subscription (non-blank; unique per organization). |
url | string | Yes | Destination endpoint that receives event POSTs. Must match ^https?://.*. |
active | boolean | Yes | Whether deliveries are attempted (true) or paused (false). |
enable_signed_webhooks | boolean | Yes | HMAC signing toggle. Must be true or false (omitting it returns 400). On create, true generates a whsec_ signing secret returned once in the response; false creates an unsigned subscription. |
events | array of strings | No | Event types to subscribe to, each in the case-sensitive Service.Event pattern (e.g. CallHandler.CallStarted) — see Event types. An empty array or ["*"] subscribes to all events. |
headers | object | No | Custom HTTP headers sent with each delivery (string→string map; e.g. auth tokens for your receiving endpoint). |
description | string | No | Free-text description (max 1500 chars). |
agent_ids | array of strings | No | Scope the subscription to specific agents (scope = AGENT). |
workspace_ids | array of strings | No | Scope the subscription to specific workspaces (scope = WORKSPACE). |
Response Body
| Field | Type | Description |
|---|
id | string | Generated subscription ID. Required for GET / PUT / DELETE. |
organization_id | string | Owning organization (resolved from your API key). |
scope | string | Resolved delivery scope: ORGANIZATION, WORKSPACE, or AGENT. |
created_user_id | string | User that created the subscription (from your API key). |
name | string | Display name for the subscription. |
url | string | Destination endpoint that receives event POSTs. |
events | array of strings | Event types the subscription is subscribed to. |
active | boolean | Whether deliveries are attempted (true) or paused (false). |
headers | object | null | Custom HTTP headers forwarded with each delivery; omitted when unset. |
description | string | null | Free-text description; omitted when unset. |
agent_ids | array of strings | null | Agent scope; omitted when unset. |
workspace_ids | array of strings | null | Workspace scope; omitted when unset. |
request_timeout_ms | integer | Per-attempt delivery timeout in ms (default 1000). |
retry_policy | object | Retry config (retryStrategy, maxRetryAttempts, maxRetryDelayMs). |
signing_enabled | boolean | Whether the subscription has a signing secret. Always present. |
secret | string | HMAC signing secret, prefixed whsec_. Present only at issuance when signing is enabled (create with enable_signed_webhooks: true). Omitted otherwise. |
created_at | string (ISO-8601) | Creation timestamp. |
updated_at | string (ISO-8601) | Last-update timestamp. |
secret is view-once. It is returned in the response body only at issuance (create with signing on, a PUT that enables signing, or the rotate-secret endpoint) and is never returned by read endpoints (GET / list). Store it as soon as you receive it; if it’s lost, obtain a new one via rotate-secret (the old one stays valid for a 72-hour grace window).
Sample Request
curl -X 'POST' \
'https://api.verbex.ai/v1/public/webhook/subscription' \
-H 'accept: */*' \
-H 'Authorization: Bearer <YOUR_API_KEY>' \
-H 'Content-Type: application/json' \
-d '{
"name": "Payment Events Webhook",
"description": "Receives payment events with custom authentication",
"url": "https://api.example.com/webhooks/payments",
"active": true,
"enable_signed_webhooks": true,
"events": [],
"headers": {
"X-Custom-Header": "custom-value",
"Authorization": "Bearer your-api-token-here"
}
}'
Sample Response — 201 Created (signed subscription; secret shown only here)
{
"id": "6a27a76692d9e3a8ffd7e62d",
"organization_id": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
"scope": "ORGANIZATION",
"created_user_id": "google-oauth2|101090478854418701727",
"name": "Payment Events Webhook",
"url": "https://api.example.com/webhooks/payments",
"events": [],
"active": true,
"headers": {
"X-Custom-Header": "custom-value",
"Authorization": "Bearer your-api-token-here"
},
"description": "Receives payment events with custom authentication",
"request_timeout_ms": 1000,
"retry_policy": {
"retryStrategy": "FIXED_DELAY",
"maxRetryAttempts": 3,
"maxRetryDelayMs": 10000
},
"signing_enabled": true,
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"created_at": "2026-06-09T10:15:00.000Z",
"updated_at": "2026-06-09T10:15:00.000Z"
}
Notes
headers are forwarded on each delivery to your url. Use them to authenticate Verbex → your endpoint (this Authorization is your receiver’s token, separate from the API key used to call this API).
- The response returns the created subscription, including its generated
id. Save that id — it’s required for GET / PUT / DELETE.
2. Update a subscription
Description: Replaces the full configuration of an existing subscription (full-replacement PUT semantics — send every field you want to keep, or it may be cleared/reset). The required enable_signed_webhooks also drives the signing lifecycle (enable or disable signing). Returns the updated subscription — including a new one-time signing secret only if this call just enabled signing on a previously-unsigned subscription.
Method: PUT /v1/public/webhook/subscription/{id}
Headers
| Header | Required | Value |
|---|
Authorization | Yes | Bearer <YOUR_API_KEY> |
Content-Type | Yes | application/json |
Path parameters
| Parameter | Description |
|---|
id | The subscription ID to update. |
Request Body
| Field | Type | Required | Description |
|---|
name | string | Yes | Display name for the subscription (non-blank; unique per organization). |
url | string | Yes | Destination endpoint that receives event POSTs. Must match ^https?://.*. |
active | boolean | Yes | Whether deliveries are attempted (true) or paused (false). |
enable_signed_webhooks | boolean | Yes | HMAC signing toggle, and the signing-lifecycle control on update (see table below). Must be true or false. |
events | array of strings | No | Event types to subscribe to, each in the case-sensitive Service.Event pattern (e.g. CallHandler.CallStarted) — see Event types. An empty array or ["*"] subscribes to all events. |
headers | object | No | Custom HTTP headers sent with each delivery (string→string map). |
description | string | No | Free-text description (max 1500 chars). |
agent_ids | array of strings | No | Scope the subscription to specific agents (scope = AGENT). |
workspace_ids | array of strings | No | Scope the subscription to specific workspaces (scope = WORKSPACE). |
Response Body
| Field | Type | Description |
|---|
id | string | Subscription ID. |
organization_id | string | Owning organization (resolved from your API key). |
scope | string | Resolved delivery scope: ORGANIZATION, WORKSPACE, or AGENT. |
created_user_id | string | User that created the subscription. |
name | string | Display name for the subscription. |
url | string | Destination endpoint that receives event POSTs. |
events | array of strings | Event types the subscription is subscribed to. |
active | boolean | Whether deliveries are attempted (true) or paused (false). |
headers | object | null | Custom HTTP headers forwarded with each delivery; omitted when unset. |
description | string | null | Free-text description; omitted when unset. |
agent_ids | array of strings | null | Agent scope; omitted when unset. |
workspace_ids | array of strings | null | Workspace scope; omitted when unset. |
request_timeout_ms | integer | Per-attempt delivery timeout in ms (default 1000). |
retry_policy | object | Retry config (retryStrategy, maxRetryAttempts, maxRetryDelayMs). |
signing_enabled | boolean | Whether the subscription has a signing secret. Always present. |
secret | string | HMAC signing secret, prefixed whsec_. Returned only if this update just enabled signing on a previously-unsigned subscription. Omitted otherwise. |
created_at | string (ISO-8601) | Creation timestamp. |
updated_at | string (ISO-8601) | Last-update timestamp. |
Signing lifecycle on update (enable_signed_webhooks):
| Value | Effect |
|---|
true on an unsigned subscription | Enables signing and returns a new secret once in the response. |
true on an already-signed subscription | No-op (secret unchanged, not returned). To replace the secret, use rotate-secret. |
false on a signed subscription | Disables signing and clears the secret irrecoverably (response shows signing_enabled: false, no secret). |
Sample Request
curl -X 'PUT' \
'https://api.verbex.ai/v1/public/webhook/subscription/6a27a76692d9e3a8ffd7e62d' \
-H 'accept: */*' \
-H 'Authorization: Bearer <YOUR_API_KEY>' \
-H 'Content-Type: application/json' \
-d '{
"name": "Payment Events Webhook",
"description": "Receives payment events with custom authentication",
"url": "https://api.example2.com/webhooks/payments",
"active": true,
"enable_signed_webhooks": true,
"events": [],
"headers": {
"X-Custom-Header": "custom-value",
"Authorization": "Bearer your-api-token-here"
}
}'
Sample Response — 200 OK (signing already enabled, so no secret returned)
{
"id": "6a27a76692d9e3a8ffd7e62d",
"organization_id": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
"scope": "ORGANIZATION",
"created_user_id": "google-oauth2|101090478854418701727",
"name": "Payment Events Webhook",
"url": "https://api.example2.com/webhooks/payments",
"events": [],
"active": true,
"headers": {
"X-Custom-Header": "custom-value",
"Authorization": "Bearer your-api-token-here"
},
"description": "Receives payment events with custom authentication",
"request_timeout_ms": 1000,
"retry_policy": {
"retryStrategy": "FIXED_DELAY",
"maxRetryAttempts": 3,
"maxRetryDelayMs": 10000
},
"signing_enabled": true,
"created_at": "2026-06-09T10:15:00.000Z",
"updated_at": "2026-06-09T11:42:00.000Z"
}
Notes
- This is a full replacement (
PUT semantics). Include every field you want to keep, or it may be cleared/reset.
updated_at changes on each successful update; created_at stays fixed.
3. Rotate the signing secret
Description: Generates a new signing secret for a subscription while keeping the previous secret valid for a 72-hour grace window, so in-flight receivers don’t break. During that window Verbex signs each delivery with both secrets (two v1= values in X-Webhook-Signature), so a receiver still using the old secret keeps verifying until it switches to the new one. The new secret is returned once in this response — store it immediately.
Method: POST /v1/public/webhook/subscription/{id}/rotate/secret
Headers
| Header | Required | Value |
|---|
Authorization | Yes | Bearer <YOUR_API_KEY> |
No request body.
Path parameters
| Parameter | Description |
|---|
id | The subscription ID whose secret to rotate. |
Response Body
Returns only the two rotation fields — not the full subscription object:
| Field | Type | Description |
|---|
secret | string | The new signing secret (whsec_…), returned once. |
previous_secret_expires_at | string (ISO-8601) | When the previous secret stops being accepted (rotation time + 72h). The prior secret value itself is not returned — you already have it. |
The response does not include id, signing_enabled, or any other subscription fields. To read the rest of the subscription after rotating, call Get a subscription.
Sample Request
curl -X 'POST' \
'https://api.verbex.ai/v1/public/webhook/subscription/6a27a76692d9e3a8ffd7e62d/rotate/secret' \
-H 'accept: */*' \
-H 'Authorization: Bearer <YOUR_API_KEY>'
Sample Response — 200 OK
{
"secret": "whsec_NEWxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"previous_secret_expires_at": "2026-06-12T10:15:00.000Z"
}
Notes
- Returns
409 Conflict if the subscription was created without signing enabled (no secret to rotate — re-create or PUT with enable_signed_webhooks: true first).
- Update your stored secret to the new value before the previous one expires; after the 72-hour window the old secret is rejected.
4. Delete a subscription
Description: Permanently deletes a subscription; Verbex stops delivering its events. This cannot be undone — to temporarily stop deliveries instead, set active: false via Update.
Method: DELETE /v1/public/webhook/subscription/{id}
Headers
| Header | Required | Value |
|---|
Authorization | Yes | Bearer <YOUR_API_KEY> |
No request body.
Path parameters
| Parameter | Description |
|---|
id | The subscription ID to delete. |
Response — 204 No Content (empty body).
Sample Request
curl -X 'DELETE' \
'https://api.verbex.ai/v1/public/webhook/subscription/6a27a76692d9e3a8ffd7e62d' \
-H 'accept: */*' \
-H 'Authorization: Bearer <YOUR_API_KEY>'
Sample Response — 204 No Content (empty body).
Notes
- Returns
404 Not Found if no subscription exists for the id.
- To pause deliveries without deleting,
PUT with "active": false — this preserves the subscription and its id.
5. Get a subscription
Description: Retrieves a single subscription by its ID. Read endpoints never return the signing secret — use signing_enabled to tell whether signing is on.
Method: GET /v1/public/webhook/subscription/{id}
Headers
| Header | Required | Value |
|---|
Authorization | Yes | Bearer <YOUR_API_KEY> |
No request body.
Path parameters
| Parameter | Description |
|---|
id | The subscription ID returned at creation. |
Response Body
| Field | Type | Description |
|---|
id | string | Subscription ID. |
organization_id | string | Owning organization. |
scope | string | ORGANIZATION, WORKSPACE, or AGENT. |
created_user_id | string | User that created the subscription. |
name | string | Display name. |
url | string | Destination endpoint. |
events | array of strings | Subscribed event types. |
active | boolean | Whether deliveries are attempted. |
headers | object | null | Custom delivery headers; omitted when unset. |
description | string | null | Free-text description; omitted when unset. |
agent_ids | array of strings | null | Agent scope; omitted when unset. |
workspace_ids | array of strings | null | Workspace scope; omitted when unset. |
request_timeout_ms | integer | Per-attempt delivery timeout (default 1000). |
retry_policy | object | Retry config (retryStrategy, maxRetryAttempts, maxRetryDelayMs). |
signing_enabled | boolean | Whether signing is on. (secret is never returned by reads.) |
created_at | string (ISO-8601) | Creation timestamp. |
updated_at | string (ISO-8601) | Last-update timestamp. |
Sample Request
curl -X 'GET' \
'https://api.verbex.ai/v1/public/webhook/subscription/6a27a76692d9e3a8ffd7e62d' \
-H 'accept: */*' \
-H 'Authorization: Bearer <YOUR_API_KEY>'
Sample Response — 200 OK (no secret)
{
"id": "6a27a76692d9e3a8ffd7e62d",
"organization_id": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
"scope": "ORGANIZATION",
"created_user_id": "google-oauth2|101090478854418701727",
"name": "Payment Events Webhook",
"url": "https://api.example.com/webhooks/payments",
"events": [],
"active": true,
"headers": {
"X-Custom-Header": "custom-value",
"Authorization": "Bearer your-api-token-here"
},
"description": "Receives payment events with custom authentication",
"request_timeout_ms": 1000,
"retry_policy": {
"retryStrategy": "FIXED_DELAY",
"maxRetryAttempts": 3,
"maxRetryDelayMs": 10000
},
"signing_enabled": true,
"created_at": "2026-06-09T10:15:00.000Z",
"updated_at": "2026-06-09T10:15:00.000Z"
}
Notes
- Returns
404 Not Found if no subscription exists for the id.
6. List subscriptions
Description: Lists the subscriptions in your organization (cursor-paginated). Note the plural subscriptions path — distinct from the singular get-by-id. secret is never included.
Method: GET /v1/public/webhook/subscriptions
Headers
| Header | Required | Value |
|---|
Authorization | Yes | Bearer <YOUR_API_KEY> |
No request body.
Query parameters
| Parameter | Required | Description |
|---|
page_size | Yes | Max items per page, 1–100. Omitting it returns 400; a value > 100 returns 400 — "Page size cannot exceed 100". |
page_token | No | Cursor from a previous response’s next_page_token; omit for the first page. |
Response Body
| Field | Type | Description |
|---|
data | array | Page of subscription objects, each the same shape as the get response (no secret). |
next_page_token | string | null | Cursor for the next page; null on the last page. |
total_count | integer | Total subscriptions matching the query. |
Sample Request
curl -X 'GET' \
'https://api.verbex.ai/v1/public/webhook/subscriptions?page_size=20' \
-H 'accept: */*' \
-H 'Authorization: Bearer <YOUR_API_KEY>'
Sample Response — 200 OK
{
"data": [
{
"id": "6a27a76692d9e3a8ffd7e62d",
"organization_id": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
"scope": "ORGANIZATION",
"created_user_id": "google-oauth2|101090478854418701727",
"name": "Payment Events Webhook",
"url": "https://api.example.com/webhooks/payments",
"events": [],
"active": true,
"request_timeout_ms": 1000,
"retry_policy": {
"retryStrategy": "FIXED_DELAY",
"maxRetryAttempts": 3,
"maxRetryDelayMs": 10000
},
"signing_enabled": true,
"created_at": "2026-06-09T10:15:00.000Z",
"updated_at": "2026-06-09T10:15:00.000Z"
}
],
"next_page_token": null,
"total_count": 1
}
Each data item has the same shape as the get response (no secret).
Event types
Values for the events array use the Service.Event pattern — the service name and event name joined by a dot (e.g. CallHandler.CallStarted). Names are case-sensitive; use them exactly as listed below (note CallHandler is PascalCase, callAnalysis is lower-camelCase). A bare event name without its service prefix (e.g. CallStarted) is rejected with 400 — Event type '…' does not exist.
Currently subscribable:
| Event | Description | Sample payload |
|---|
CallHandler.CallStarted | Triggered when a call is initiated. | {"call_id": "69ef5c4...3c729ab8c83f", "status": "success", "agent_id": "c51ab1b1-...-86a922d694e7"} |
CallHandler.CallEnded | Triggered when a call is ended. | {"call_id": "69ef5c43...29ab8c83f", "status": "success", "agent_id": "c51ab1b1-...-86a922d694e7"} |
callAnalysis.pcaCompleted | Triggered when post-call analysis (PCA) completes. | {"call_id": "698820c3...28d6b98c63", "agent_id": "285aef8f-...-3573855ed4a0", "status": "success", "reason": "success", "analysisTimestamp": 1770529007989} |
Notes
- An empty array (
[]) or ["*"] subscribes to all subscribable event types.
- Some event types exist but are internal-only and cannot be subscribed to via public webhooks — subscribing to them returns
400 — Event type '…' is internal-only and cannot be subscribed to via public webhooks (e.g. CallHandler.AnsweringMachineDetected).
Event Delivery
Every delivery is an HTTP POST to your subscription url with a JSON body in the following envelope. The event-specific data lives under payload; the surrounding fields are the same for every event type. Test deliveries use the identical envelope (the only difference is payload comes from the event type’s sample payload and traceId is prefixed test-).
Envelope fields
| Field | Type | Description |
|---|
eventName | string | The full event type in Service.Event form (e.g. CallHandler.CallStarted). ⚠️ Deprecated — being phased out and will be removed once consumers migrate to event. Prefer event for new integrations. |
event | string | The event name without its service prefix (e.g. CallStarted). Going-forward field — use this instead of eventName. |
traceId | string | Correlation/trace ID for this delivery (also sent as the X-Webhook-TraceId header). |
timestamp | string (ISO-8601) | When the event was produced. |
organizationId | string | The organization the event belongs to. |
payload | object | The event-specific body — see the Event types table for the shape per event (e.g. call_id, agent_id, status). |
Sample delivery — CallHandler.CallStarted
{
"eventName": "CallHandler.CallStarted",
"event": "CallStarted",
"traceId": "9f1c2e7a-...-b3d4",
"timestamp": "2026-06-09T10:15:00.000Z",
"organizationId": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
"payload": {
"call_id": "69ef5c4...3c729ab8c83f",
"status": "success",
"agent_id": "c51ab1b1-...-86a922d694e7"
}
}
Sample delivery — callAnalysis.pcaCompleted
{
"eventName": "callAnalysis.pcaCompleted",
"event": "pcaCompleted",
"traceId": "9f1c2e7a-...-b3d4",
"timestamp": "2026-06-09T10:15:00.000Z",
"organizationId": "ddd6f1d8-b232-497e-bf1e-ea7317622d17",
"payload": {
"call_id": "698820c3...28d6b98c63",
"agent_id": "285aef8f-...-3573855ed4a0",
"status": "success",
"reason": "success",
"analysisTimestamp": 1770529007989
}
}
Delivery headers
| Header | Always sent | Description |
|---|
Content-Type | Yes | application/json. |
X-Webhook-Event | Yes | The full event type, in Service.Event form (e.g. CallHandler.CallStarted) — same value as the body’s eventName. Routing hint. |
X-Webhook-TraceId | Yes | Correlation/trace ID for this delivery — same value as the body’s traceId. |
X-Webhook-Timestamp | Yes | Event timestamp as an ISO-8601 string (e.g. 2026-06-09T10:15:00Z) — same value as the body’s timestamp. |
X-Webhook-Signature | Only when signed | HMAC signature, format t=<unix_seconds>,v1=<hex>[,v1=<hex>]. Present only if the subscription has signing enabled. See Verifying webhook signatures. |
| custom headers | If configured | Any headers you set on the subscription are forwarded as-is. |
X-Webhook-Event, X-Webhook-TraceId, and X-Webhook-Timestamp are routing hints and are not covered by the HMAC signature — do not rely on them for security. Only the raw request body and the t value inside X-Webhook-Signature are signed.Note: t (inside X-Webhook-Signature) is a Unix epoch-seconds integer and is what the replay check uses; the separate X-Webhook-Timestamp header is an ISO-8601 string. They are different formats and values — don’t conflate them.
Stable vs. optional fields
Verbex maintains backward compatibility: the fields below remain stable, and new optional fields may be added over time. Your receiver should tolerate unknown fields rather than strictly validate the whole body.
Stable — safe to rely on:
event
traceId
timestamp
organizationId
payload.call_id
payload.agent_id
payload.status
eventName is deprecated and will be removed in a future version — migrate to the event field. It is still sent today for backward compatibility, but do not build new integrations on it.
Optional / may change or be added later: additional payload fields (e.g. reason, analysisTimestamp, and event-specific extras), and any future top-level fields.
Webhook subscription changes, including deletion, may take up to 10 minutes to fully propagate due to internal caching.
Verifying webhook signatures (client side)
When enable_signed_webhooks is true, every delivery carries an X-Webhook-Signature header. Verify it before trusting the payload.
Header format
X-Webhook-Signature: t=<unix_seconds>,v1=<hmac_sha256_hex>[,v1=<hmac_sha256_hex>]
t — Unix timestamp (seconds) when the delivery was signed.
v1 — lowercase hex HMAC-SHA256. During the 72-hour grace window after a secret rotation, two v1= values are sent (new secret first, then previous) — accept the request if either matches.
What is signed: the raw request body, a literal ., then t:
signed_payload = <raw_request_body> + "." + <t>
Key: use the full whsec_… secret string exactly as returned, as UTF-8 bytes. Do not strip the whsec_ prefix and do not base64-decode it.
Steps
- Parse
t and all v1 values from X-Webhook-Signature.
- Reject if
|now − t| > 300 seconds (replay protection).
- Compute
expected = hex(HMAC_SHA256(secret, raw_body + "." + t)).
- Accept if
expected equals any v1 (use a constant-time comparison).
- Don’t rely on
X-Webhook-Event, X-Webhook-TraceId, or X-Webhook-Timestamp for security — they are routing hints and are not covered by the signature.
Verify against the raw request body bytes exactly as received. Parsing then re-serializing the JSON will change the bytes and break the signature.
Node.js (Express)
const crypto = require("crypto");
function verifyWebhook(rawBody, sigHeader, secret, toleranceSec = 300) {
let t;
const sigs = [];
for (const seg of sigHeader.split(",")) {
const i = seg.indexOf("=");
const k = seg.slice(0, i), v = seg.slice(i + 1);
if (k === "t") t = parseInt(v, 10);
else if (k === "v1") sigs.push(v);
}
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret) // secret includes the whsec_ prefix
.update(`${rawBody}.${t}`)
.digest("hex");
return sigs.some(
(s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))
);
}
// Capture the RAW body so the bytes are unchanged
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks", (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyWebhook(raw, req.get("X-Webhook-Signature"), process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
// ... handle event ...
res.sendStatus(200);
});
Python (Flask)
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, sig_header: str, secret: str, tolerance: int = 300) -> bool:
t, sigs = None, []
for seg in sig_header.split(","):
k, _, v = seg.partition("=")
if k == "t":
t = int(v)
elif k == "v1":
sigs.append(v)
if t is None or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(
secret.encode("utf-8"), # full whsec_... string
raw_body + b"." + str(t).encode("utf-8"),
hashlib.sha256,
).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
# Flask: request.get_data() returns the raw bytes
# ok = verify_webhook(request.get_data(), request.headers["X-Webhook-Signature"], WEBHOOK_SECRET)
Error responses
Errors are returned with a non-2xx status code and this JSON body:
{
"status": 400,
"type": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"fields": {
"enableSignedWebhooks": "enable_signed_webhooks is required (true or false)"
}
},
"timestamp": "2026-06-24T13:24:36.078Z",
"path": "/v1/public/webhook/subscription",
"trace_id": "341b5325-a0be-4533-85dd-6f697476dfc3"
}
| Field | Description |
|---|
status | HTTP status code. |
type | Machine-readable error type (e.g. VALIDATION_ERROR). |
message | Human-readable summary. |
details | Optional structured detail (e.g. fields → per-field validation messages). |
timestamp / path / trace_id | When/where it happened and a correlation ID for support. |
Authentication errors use a different shape. The schema above is returned by the webhook service for application-level errors (400, 404, 409, 500). Authentication failures rejected at the API gateway (401) return a leaner body with only error, message, and a camelCase traceId — no status, type, path, or timestamp:{
"error": "Authentication",
"message": "Authentication failed",
"traceId": "e679120f-1d1c-41d2-947c-34e194d5a4a1"
}
The message text is informational (e.g. "Authentication failed" for an invalid token, "Authentication failure" when the Authorization header is missing). Note traceId is camelCase here, unlike the snake_case trace_id on service-level errors. Parse error bodies defensively: rely on the HTTP status code, and treat status/type/path/trace_id as present only on service-level errors.
| Status | Meaning |
|---|
400 Bad Request | Malformed JSON or invalid/missing fields (e.g. missing enable_signed_webhooks, invalid url). |
401 Unauthorized | Missing or invalid Authorization Bearer token. Returned by the gateway in the leaner shape above (error, message, camelCase traceId only). |
403 Forbidden | Token is valid but not permitted for this resource. |
404 Not Found | No subscription exists for the given id. |
409 Conflict | Duplicate name (unique per organization), or rotate-secret on a subscription without signing enabled. |
429 Too Many Requests | Rate limit exceeded. |
500 Internal Server Error | Unexpected server-side failure. |
Quick reference
| Action | Method | Path | Body | Success |
|---|
| Create | POST | /v1/public/webhook/subscription | Yes | 201 |
| Read | GET | /v1/public/webhook/subscription/{id} | No | 200 |
| Update | PUT | /v1/public/webhook/subscription/{id} | Yes | 200 |
| Delete | DELETE | /v1/public/webhook/subscription/{id} | No | 204 |
| List | GET | /v1/public/webhook/subscriptions | No | 200 |
| Rotate secret | POST | /v1/public/webhook/subscription/{id}/rotate/secret | No | 200 |
| Header | Used on | Value |
|---|
Authorization | All requests | Bearer <YOUR_API_KEY> |
Content-Type | POST, PUT | application/json |
accept | All requests | */* |
Tips
events: [] — an empty array (or ["*"]) subscribes to all event types. To receive only specific events, list them explicitly (see Event types).
- Verify delivery — your
url should respond 2xx quickly. Use the headers field to let your receiver authenticate inbound calls.
- Pause vs. delete — to temporarily stop deliveries,
PUT with "active": false rather than deleting; this preserves the subscription and its ID.