Skip to main content
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.
Using signed webhooks (client side): on every delivery, verify the X-Webhook-Signature header with your stored secret before trusting the payload — see Verifying webhook signatures (client side).
Method: POST /v1/public/webhook/subscription Headers
HeaderRequiredValue
AuthorizationYesBearer <YOUR_API_KEY>
Content-TypeYesapplication/json
Request Body
FieldTypeRequiredDescription
namestringYesDisplay name for the subscription (non-blank; unique per organization).
urlstringYesDestination endpoint that receives event POSTs. Must match ^https?://.*.
activebooleanYesWhether deliveries are attempted (true) or paused (false).
enable_signed_webhooksbooleanYesHMAC 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.
eventsarray of stringsNoEvent 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.
headersobjectNoCustom HTTP headers sent with each delivery (string→string map; e.g. auth tokens for your receiving endpoint).
descriptionstringNoFree-text description (max 1500 chars).
agent_idsarray of stringsNoScope the subscription to specific agents (scope = AGENT).
workspace_idsarray of stringsNoScope the subscription to specific workspaces (scope = WORKSPACE).
Response Body
FieldTypeDescription
idstringGenerated subscription ID. Required for GET / PUT / DELETE.
organization_idstringOwning organization (resolved from your API key).
scopestringResolved delivery scope: ORGANIZATION, WORKSPACE, or AGENT.
created_user_idstringUser that created the subscription (from your API key).
namestringDisplay name for the subscription.
urlstringDestination endpoint that receives event POSTs.
eventsarray of stringsEvent types the subscription is subscribed to.
activebooleanWhether deliveries are attempted (true) or paused (false).
headersobject | nullCustom HTTP headers forwarded with each delivery; omitted when unset.
descriptionstring | nullFree-text description; omitted when unset.
agent_idsarray of strings | nullAgent scope; omitted when unset.
workspace_idsarray of strings | nullWorkspace scope; omitted when unset.
request_timeout_msintegerPer-attempt delivery timeout in ms (default 1000).
retry_policyobjectRetry config (retryStrategy, maxRetryAttempts, maxRetryDelayMs).
signing_enabledbooleanWhether the subscription has a signing secret. Always present.
secretstringHMAC signing secret, prefixed whsec_. Present only at issuance when signing is enabled (create with enable_signed_webhooks: true). Omitted otherwise.
created_atstring (ISO-8601)Creation timestamp.
updated_atstring (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 Response201 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
HeaderRequiredValue
AuthorizationYesBearer <YOUR_API_KEY>
Content-TypeYesapplication/json
Path parameters
ParameterDescription
idThe subscription ID to update.
Request Body
FieldTypeRequiredDescription
namestringYesDisplay name for the subscription (non-blank; unique per organization).
urlstringYesDestination endpoint that receives event POSTs. Must match ^https?://.*.
activebooleanYesWhether deliveries are attempted (true) or paused (false).
enable_signed_webhooksbooleanYesHMAC signing toggle, and the signing-lifecycle control on update (see table below). Must be true or false.
eventsarray of stringsNoEvent 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.
headersobjectNoCustom HTTP headers sent with each delivery (string→string map).
descriptionstringNoFree-text description (max 1500 chars).
agent_idsarray of stringsNoScope the subscription to specific agents (scope = AGENT).
workspace_idsarray of stringsNoScope the subscription to specific workspaces (scope = WORKSPACE).
Response Body
FieldTypeDescription
idstringSubscription ID.
organization_idstringOwning organization (resolved from your API key).
scopestringResolved delivery scope: ORGANIZATION, WORKSPACE, or AGENT.
created_user_idstringUser that created the subscription.
namestringDisplay name for the subscription.
urlstringDestination endpoint that receives event POSTs.
eventsarray of stringsEvent types the subscription is subscribed to.
activebooleanWhether deliveries are attempted (true) or paused (false).
headersobject | nullCustom HTTP headers forwarded with each delivery; omitted when unset.
descriptionstring | nullFree-text description; omitted when unset.
agent_idsarray of strings | nullAgent scope; omitted when unset.
workspace_idsarray of strings | nullWorkspace scope; omitted when unset.
request_timeout_msintegerPer-attempt delivery timeout in ms (default 1000).
retry_policyobjectRetry config (retryStrategy, maxRetryAttempts, maxRetryDelayMs).
signing_enabledbooleanWhether the subscription has a signing secret. Always present.
secretstringHMAC signing secret, prefixed whsec_. Returned only if this update just enabled signing on a previously-unsigned subscription. Omitted otherwise.
created_atstring (ISO-8601)Creation timestamp.
updated_atstring (ISO-8601)Last-update timestamp.
Signing lifecycle on update (enable_signed_webhooks):
ValueEffect
true on an unsigned subscriptionEnables signing and returns a new secret once in the response.
true on an already-signed subscriptionNo-op (secret unchanged, not returned). To replace the secret, use rotate-secret.
false on a signed subscriptionDisables 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 Response200 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
HeaderRequiredValue
AuthorizationYesBearer <YOUR_API_KEY>
No request body. Path parameters
ParameterDescription
idThe subscription ID whose secret to rotate.
Response Body Returns only the two rotation fields — not the full subscription object:
FieldTypeDescription
secretstringThe new signing secret (whsec_…), returned once.
previous_secret_expires_atstring (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 Response200 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
HeaderRequiredValue
AuthorizationYesBearer <YOUR_API_KEY>
No request body. Path parameters
ParameterDescription
idThe subscription ID to delete.
Response204 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 Response204 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
HeaderRequiredValue
AuthorizationYesBearer <YOUR_API_KEY>
No request body. Path parameters
ParameterDescription
idThe subscription ID returned at creation.
Response Body
FieldTypeDescription
idstringSubscription ID.
organization_idstringOwning organization.
scopestringORGANIZATION, WORKSPACE, or AGENT.
created_user_idstringUser that created the subscription.
namestringDisplay name.
urlstringDestination endpoint.
eventsarray of stringsSubscribed event types.
activebooleanWhether deliveries are attempted.
headersobject | nullCustom delivery headers; omitted when unset.
descriptionstring | nullFree-text description; omitted when unset.
agent_idsarray of strings | nullAgent scope; omitted when unset.
workspace_idsarray of strings | nullWorkspace scope; omitted when unset.
request_timeout_msintegerPer-attempt delivery timeout (default 1000).
retry_policyobjectRetry config (retryStrategy, maxRetryAttempts, maxRetryDelayMs).
signing_enabledbooleanWhether signing is on. (secret is never returned by reads.)
created_atstring (ISO-8601)Creation timestamp.
updated_atstring (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 Response200 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
HeaderRequiredValue
AuthorizationYesBearer <YOUR_API_KEY>
No request body. Query parameters
ParameterRequiredDescription
page_sizeYesMax items per page, 1100. Omitting it returns 400; a value > 100 returns 400 — "Page size cannot exceed 100".
page_tokenNoCursor from a previous response’s next_page_token; omit for the first page.
Response Body
FieldTypeDescription
dataarrayPage of subscription objects, each the same shape as the get response (no secret).
next_page_tokenstring | nullCursor for the next page; null on the last page.
total_countintegerTotal 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 Response200 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:
EventDescriptionSample payload
CallHandler.CallStartedTriggered when a call is initiated.{"call_id": "69ef5c4...3c729ab8c83f", "status": "success", "agent_id": "c51ab1b1-...-86a922d694e7"}
CallHandler.CallEndedTriggered when a call is ended.{"call_id": "69ef5c43...29ab8c83f", "status": "success", "agent_id": "c51ab1b1-...-86a922d694e7"}
callAnalysis.pcaCompletedTriggered 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
FieldTypeDescription
eventNamestringThe 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.
eventstringThe event name without its service prefix (e.g. CallStarted). Going-forward field — use this instead of eventName.
traceIdstringCorrelation/trace ID for this delivery (also sent as the X-Webhook-TraceId header).
timestampstring (ISO-8601)When the event was produced.
organizationIdstringThe organization the event belongs to.
payloadobjectThe 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
HeaderAlways sentDescription
Content-TypeYesapplication/json.
X-Webhook-EventYesThe full event type, in Service.Event form (e.g. CallHandler.CallStarted) — same value as the body’s eventName. Routing hint.
X-Webhook-TraceIdYesCorrelation/trace ID for this delivery — same value as the body’s traceId.
X-Webhook-TimestampYesEvent timestamp as an ISO-8601 string (e.g. 2026-06-09T10:15:00Z) — same value as the body’s timestamp.
X-Webhook-SignatureOnly when signedHMAC signature, format t=<unix_seconds>,v1=<hex>[,v1=<hex>]. Present only if the subscription has signing enabled. See Verifying webhook signatures.
custom headersIf configuredAny 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
  1. Parse t and all v1 values from X-Webhook-Signature.
  2. Reject if |now − t| > 300 seconds (replay protection).
  3. Compute expected = hex(HMAC_SHA256(secret, raw_body + "." + t)).
  4. Accept if expected equals any v1 (use a constant-time comparison).
  5. 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"
}
FieldDescription
statusHTTP status code.
typeMachine-readable error type (e.g. VALIDATION_ERROR).
messageHuman-readable summary.
detailsOptional structured detail (e.g. fields → per-field validation messages).
timestamp / path / trace_idWhen/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.
StatusMeaning
400 Bad RequestMalformed JSON or invalid/missing fields (e.g. missing enable_signed_webhooks, invalid url).
401 UnauthorizedMissing or invalid Authorization Bearer token. Returned by the gateway in the leaner shape above (error, message, camelCase traceId only).
403 ForbiddenToken is valid but not permitted for this resource.
404 Not FoundNo subscription exists for the given id.
409 ConflictDuplicate name (unique per organization), or rotate-secret on a subscription without signing enabled.
429 Too Many RequestsRate limit exceeded.
500 Internal Server ErrorUnexpected server-side failure.

Quick reference

ActionMethodPathBodySuccess
CreatePOST/v1/public/webhook/subscriptionYes201
ReadGET/v1/public/webhook/subscription/{id}No200
UpdatePUT/v1/public/webhook/subscription/{id}Yes200
DeleteDELETE/v1/public/webhook/subscription/{id}No204
ListGET/v1/public/webhook/subscriptionsNo200
Rotate secretPOST/v1/public/webhook/subscription/{id}/rotate/secretNo200

Common headers

HeaderUsed onValue
AuthorizationAll requestsBearer <YOUR_API_KEY>
Content-TypePOST, PUTapplication/json
acceptAll 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.