Skip to content

Webhooks API

This document describes the Webhooks API endpoints as implemented.

Overview

The Webhooks API manages webhook subscriptions for real-time event notifications. When events occur that match a subscription's event types, the platform delivers them via HTTP POST to the registered URL.

Base Path: /api/v1/webhooks/subscriptions
Authentication: API Key with webhooks:write scope

Endpoints

Create Subscription

Creates a new webhook subscription.

Method: POST
Path: /api/v1/webhooks/subscriptions
Rate Limit Bucket: write

Request Body

Field Required Description
url Yes Target URL for webhook delivery (HTTPS required in production)
eventTypes Yes Array of event types to subscribe to
enabled No Whether subscription is active (default: true)
signingSecret No Custom signing secret (auto-generated if omitted)
name No Friendly name (auto-generated if omitted)

Validation

  • URL must be absolute and HTTPS (HTTP allowed in Development only)
  • URL maximum length: 500 characters
  • At least one event type required
  • Event types normalized to lowercase and deduplicated
  • Event types CSV maximum length: 1000 characters
  • Signing secret maximum length: 500 characters
  • Request body maximum: 512KB
  • SSRF protection: blocks private/loopback IPs in production

Response

201 Created: Returns subscription with signing secret.

{
  "id": 123,
  "url": "https://example.com/webhook",
  "testUrl": null,
  "enabled": true,
  "eventTypes": ["client.created"],
  "hasSigningSecret": true,
  "signingSecret": "abc123...",
  "createdUtc": "2026-01-28T12:00:00Z"
}

Important: signingSecret is returned only on creation. Store it securely; it cannot be retrieved later.

The Location header contains the URL of the created subscription.

List Subscriptions

Retrieves all subscriptions for the authenticated company.

Method: GET
Path: /api/v1/webhooks/subscriptions

Response

200 OK: Returns { items: [...] } with array of subscriptions.

Signing secrets are never included in list responses. Only hasSigningSecret boolean is returned.

Get Subscription

Retrieves a specific subscription.

Method: GET
Path: /api/v1/webhooks/subscriptions/{id}
Rate Limit Bucket: read

Response

  • 200 OK: Returns subscription details
  • 404 Not Found: Subscription does not exist or belongs to different company

Update Subscription

Updates an existing subscription.

Method: PATCH
Path: /api/v1/webhooks/subscriptions/{id}
Rate Limit Bucket: write

Request Body

All fields are optional. Only provided fields are updated.

Field Description
url New target URL
testUrl New test URL (empty string to clear)
eventTypes New event types array
isEnabled Enable/disable subscription

Tool-Managed Subscriptions

Subscriptions created by external tools (Zapier, n8n, etc.) are marked as "tool-managed" and have restrictions: - Protected fields (url, eventTypes, testUrl) cannot be modified - Only isEnabled can be changed

Attempting to modify protected fields returns 403 with error message indicating the managing system.

Response

  • 200 OK: Returns updated subscription
  • 400 Bad Request: Validation failed
  • 403 Forbidden: Subscription is tool-managed
  • 404 Not Found: Subscription not found

Delete Subscription

Deletes a subscription.

Method: DELETE
Path: /api/v1/webhooks/subscriptions/{id}
Rate Limit Bucket: write

Response

  • 204 No Content: Successfully deleted
  • 404 Not Found: Subscription not found or belongs to different company

Get Sample Payload

Generates a sample webhook payload for a specific event type.

Method: GET
Path: /api/v1/webhooks/subscriptions/{id}/sample
Rate Limit Bucket: read

Query Parameters

Parameter Required Description
eventType Yes Event type to generate sample for

The event type must be one the subscription is configured to receive.

Response

  • 200 OK: Returns sample webhook envelope
  • 400 Bad Request: Event type not subscribed or not supported
  • 404 Not Found: Subscription not found

Test Subscription

Sends a test webhook delivery to validate the endpoint.

Method: POST
Path: /api/v1/webhooks/subscriptions/{id}/test
Rate Limit Bucket: admin

Request Body

Field Description
eventType Optional event type for realistic sample
samplePayload Optional custom payload
useSamplePayload Use SQL-generated sample (default: true)
statusOverride Optional status to include in metadata

Response

200 OK: Returns test result (check success field for actual delivery status).

{
  "success": true,
  "statusCode": 200,
  "elapsedMs": 145,
  "responseBodyTruncated": false,
  "responseBody": "OK",
  "targetUrlUsed": "https://example.com/webhook"
}

Webhook Delivery

Delivery Guarantees

  • At-least-once delivery: Events may be delivered multiple times during retries
  • Idempotency: Use the id field (event ID) to deduplicate on your end
  • Event ordering: Best-effort chronological, NOT guaranteed during retries
  • Success criteria: 2xx HTTP status codes (200-299)
  • Timeout: 10 seconds per delivery attempt

Retry Policy

  • Up to 10 total attempts (initial + 9 retries)
  • Exponential backoff: 2^attempt minutes, capped at 360 minutes
  • Example schedule: Initial, +4m, +8m, +16m, +32m, +64m, +128m, +256m, +360m, +360m

Circuit Breaker

Subscriptions are auto-disabled after 20 consecutive delivery failures (configurable).

Signature Headers

Each delivery includes: - X-Webhook-Timestamp: Unix epoch seconds (string) - X-Webhook-Signature: sha256=<lowercase_hex>

Verifying Signatures

To ensure webhook deliveries are authentic and haven't been tampered with:

  1. Extract the X-Webhook-Timestamp and X-Webhook-Signature headers
  2. Compute HMAC-SHA256 of "{timestamp}.{raw_request_body}" using the signing secret
  3. Convert the hash to lowercase hex and prepend sha256=
  4. Compare with X-Webhook-Signature using constant-time comparison
  5. Recommended: Reject if timestamp is too old (e.g., > 5 minutes) for replay protection
Code Examples #### C# Example
var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString();
var receivedSig = Request.Headers["X-Webhook-Signature"].ToString();
var payload = $"{timestamp}.{requestBody}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var computedSig = "sha256=" + BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
    Encoding.UTF8.GetBytes(computedSig), 
    Encoding.UTF8.GetBytes(receivedSig)))
{
    return Unauthorized();
}
#### Node.js Example
const crypto = require('crypto');

const timestamp = req.headers['x-webhook-timestamp'];
const receivedSig = req.headers['x-webhook-signature'];
const payload = `${timestamp}.${rawBody}`;
const computedSig = 'sha256=' + crypto
  .createHmac('sha256', signingSecret)
  .update(payload)
  .digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(computedSig), Buffer.from(receivedSig))) {
  return res.status(401).send('Invalid signature');
}

Delivery Logging

Every delivery attempt is logged: - Subscription ID - Event ID - HTTP response code - Response body (truncated at 4000 characters) - Elapsed time in milliseconds

URL Validation

Production Requirements

  • HTTPS required
  • Hostname resolved and validated
  • Private IP ranges blocked (10.x, 172.16.x, 192.168.x)
  • Loopback addresses blocked (127.x, localhost)

Development Mode

HTTP URLs and local targets can be allowed via configuration: - AllowLocalWebhookTargets: true in settings - Only effective in Development environment

Signing Secret Storage

  • Generated secrets: 32 random bytes, Base64 encoded
  • Encrypted with AES-GCM before storage
  • Versioned encryption (v2: prefix) with backward compatibility
  • Never returned in list/get operations after creation