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
429Too Many Requests Error