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
idfield (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:
- Extract the
X-Webhook-TimestampandX-Webhook-Signatureheaders - Compute HMAC-SHA256 of
"{timestamp}.{raw_request_body}"using the signing secret - Convert the hash to lowercase hex and prepend
sha256= - Compare with
X-Webhook-Signatureusing constant-time comparison - Recommended: Reject if timestamp is too old (e.g., > 5 minutes) for replay protection
Code Examples
#### C# Examplevar 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();
}
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