Create a webhook

View as Markdown
Registers an endpoint that receives signed `POST` requests when subscribed events happen in the workspace. The response is the only time the plaintext signing `secret` is returned. Store it before doing anything else: it cannot be read back, only replaced with `POST /api/webhooks/{webhookId}/rotate-secret`. The URL is checked twice. First its syntax: `http`/`https` only, no private, loopback, link-local or internal hostnames or IPs, and no dangerous ports such as 22, 23, 25, 53 or 445 (`400`). Then its hostname is resolved via DNS. A hostname that does not resolve, or that resolves to a private address, is refused with a generic `500`. The same DNS check runs again on every delivery. **Side effects.** Stores the webhook, active and with `failure_count: 0`. It starts receiving matching events emitted from then on; past events are not replayed. The request is written to the audit log as `webhook.create`, and rejected attempts as `FAILED_MUTATION:webhook.create`. **Idempotency.** Not idempotent. Retrying after a timeout can create a second webhook with a different secret, and your endpoint would then receive every event twice. Before retrying, call `GET /api/webhooks` and look for the URL. **Webhook events.** Emits `audit.log_recorded` to webhooks subscribed to it. See [Webhooks](/webhooks). **Event catalog.** `events` accepts every name in `WebhookEventName` plus `"*"`. `"*"` covers every event except `audit.log_recorded`, which must be named explicitly. Only these events are delivered today: | Event | Sent when | `data` fields | | --- | --- | --- | | `call.completed` | A call finishes and its analysis is done | `callId`, `campaignId`, `contactId`, `agentId`, `phoneNumber`, `outcome`, `sentimentScore`, `duration`, `summary`, `dataCollection`, `kpiData` | | `call.failed` | A call ends in a technical failure, or reconciliation closes it as failed or unanswered | Same as `call.completed`. Reconciled calls send `callId`, `agentId`, `campaignId`, `contactId`, `phoneNumber`, `outcome`, `duration` and `reconciled: true` | | `campaign.completed` | Every contact in a campaign has been processed | `campaignId`, `campaignName` | | `crm_sync.completed` / `crm_sync.failed` | A call or conversation was written to a connected CRM, or failed to reach it | `channel`, `source`, `callId`, `conversationId`, `agentId`, `campaignId`, `contactId`, `results[]` (`provider`, `success`, `externalId`, `error`) | | `agent.action_recorded` | An agent invoked a tool (one event per invocation) | The action record in snake_case: `action_id`, `agent_id`, `tool_name`, `params` (redacted), `outcome`, `result_summary`, `duration_ms`, token and cost fields, `occurred_at`, … | | `audit.log_recorded` | An audited API request was made (one event per request) | `audit_id`, `tenant_id`, `user_id`, `action`, `resource_type`, `resource_id`, `changes`, `ip_address`, `user_agent`, `occurred_at`, `redacted_at`, `redaction_reason` | You can also subscribe to the other catalog events (`call.started`, `call.recording_ready`, `call.transcript_ready`, `agent.created`, `agent.updated`, `campaign.started`, `campaign.paused`, `contact.*`, `usage.threshold_reached`, `subscription.changed`, `integration.*`), but nothing is delivered for them yet. **Delivery contract.** Each event is sent as `POST <url>` with `Content-Type: application/json`. Without a `payload_template`, the body is `{"event": "...", "timestamp": "...", "data": {...}}` (see `WebhookDeliveryPayload`). Headers sent: | Header | Value | | --- | --- | | `X-Webhook-Signature-V2` | Lowercase hex HMAC-SHA256 of `<X-Webhook-Timestamp>.<raw body>`, keyed with the full secret string (including `whsec_`). No `sha256=` prefix. | | `X-Webhook-Timestamp` | ISO 8601 UTC with milliseconds, e.g. `2026-09-14T15:04:05.123Z`. Set once per event and identical on every retry. | | `X-Webhook-Event` | The event name. | | `X-Webhook-Attempt` | `1` to `5`. | | `X-Webhook-Signature`, `X-Webhook-Signature-V1` | Legacy. Lowercase hex HMAC-SHA256 of the raw body only; both headers carry the same value. Sent by default for backward compatibility and scheduled for removal. Verify `X-Webhook-Signature-V2` instead. | Your custom `headers` are added too, except `Host`, `Content-Length` and any name starting with `X-Webhook-`. To verify a delivery, compute the V2 HMAC over the exact bytes received and compare it in constant time. Reject timestamps outside your tolerance window, allowing a few minutes because retries reuse the original timestamp. **Success, retries and auto-disable.** - Any `2xx` answered within 10 seconds is a success. Redirects are not followed. - A timeout, network error or `5xx` is retried, up to 5 attempts in total, waiting 5 s, 10 s, 20 s and 40 s between attempts. - Any other status (including `3xx`, `4xx` and `429`) fails at once, with no retry. - Every attempt is recorded in the delivery log, which keeps the last 25. - An event that fails for good adds 1 to `failure_count`. That includes a delivery refused before sending, for invalid custom headers or a template that does not render. Any success resets the count. At 10 the webhook is set `is_active: false`, stops receiving events and the workspace is notified. Re-enable it with `PATCH` `{"is_active": true}`. **Access** - **Required scope:** `full`. Human users need the owner or admin role. - **Rate limit:** General API (120–600 requests/min per workspace, by plan) plus the configuration-mutations limit — 10 requests/min per workspace, shared with other configuration changes such as agents, integrations and exports. See [Rate limits](/rate-limits). - **Plan:** Requires the `webhook` plan feature. Every plan includes it today, so this gate currently refuses nobody.

Authentication

AuthorizationBearer
Workspace API key: `jl_` followed by 64 lowercase hex characters, created by the workspace owner in the dashboard (**Settings → API Keys**) and sent as `Authorization: Bearer jl_...`. The plaintext is shown once, at creation; Jelliu stores only a SHA-256 hash. A workspace can hold up to 25 active keys. | Scope | GET / HEAD | POST / PUT / PATCH / DELETE | Admin-only routes | | --- | --- | --- | --- | | `read` | Yes | No | No | | `write` | Yes | Yes | No | | `full` | Yes | Yes | Yes | Operations restricted to admins or owners reject keys without the `full` scope with `403`, and say so in their description. No key, whatever its scope, can mint or revoke API keys or rotate a webhook secret — that requires a signed-in owner session. A revoked key stops authenticating within about 10 seconds. See [Authentication](/authentication).

Request

This endpoint expects an object.
urlstringRequiredformat: "uri"<=2000 characters

http or https URL, up to 2000 characters. Private, loopback, link-local and internal hostnames or IPs, and ports such as 22, 23, 25, 53 and 445, are rejected with 400. The hostname is also resolved via DNS when the webhook is created and every time a delivery is sent.

eventslist of enumsRequired

Events to receive, at least one. "*" subscribes to every event except audit.log_recorded, which must be named explicitly.

descriptionstringOptional<=500 characters

Free-text label, up to 500 characters.

filtersobjectOptional

Optional delivery filters, combined with AND. The ID, outcome and sentiment filters fail closed: if the event’s data does not carry the field, the event is NOT delivered. They are matched against data.campaignId/data.campaign_id, data.agentId/data.agent_id, data.outcome/data.result and data.sentimentScore/data.sentiment. Filtered-out events leave no delivery log entry.

headersmap from strings to stringsOptional

Extra request headers sent with every delivery. Names up to 200 characters, values up to 2000. Host, Content-Length and names starting with X-Webhook- are ignored. A name that is not a valid HTTP token, or a value containing CR, LF or NUL, is not rejected here: each delivery then fails before sending and is logged with status 0.

payload_templatestringOptional<=10000 characters

Replaces the default {event, timestamp, data} body. Supported placeholders are {{event}}, {{timestamp}} and {{data.<path>}} (dot notation). Inside a JSON string the value is inserted escaped; outside a string it is inserted as a JSON token. A field missing from the event renders as null, or as an empty string inside quotes. The template must render to valid JSON. Any other placeholder root, or a template that does not render, is rejected with 400.

Response

Created. data.secret holds the plaintext signing secret, returned only in this response.

dataobject

An outbound webhook exactly as the API serializes it: the stored row with snake_case keys. secret is masked as "[configured]" on list, retrieve and update. The plaintext signing secret is returned only once, in the POST /api/webhooks response, and a new one by rotate-secret. Custom headers are returned as stored, in plaintext.

Errors

400
Bad Request Error
401
Unauthorized Error
403
Forbidden Error
429
Too Many Requests Error
500
Internal Server Error