Webhook endpoints
The endpoints your organization's webhooks are delivered to — the same ones Manage → Webhooks edits. Every rule the dashboard applies applies here: the URL's checks, the template's fields, the filters. A change made here shows there at once, and the other way round.
| Route | Scope | Does |
|---|---|---|
GET /api/public/v1/webhooks | webhook:read | A page of endpoints, newest first |
POST /api/public/v1/webhooks | webhook:write | Creates an endpoint, with its secret |
GET /api/public/v1/webhooks/{id} | webhook:read | One endpoint |
PATCH /api/public/v1/webhooks/{id} | webhook:write | Changes its settings, or switches it |
DELETE /api/public/v1/webhooks/{id} | webhook:write | Deletes it |
POST /api/public/v1/webhooks/{id}/rotate-secret | webhook:write | A new signing secret |
POST /api/public/v1/webhooks/{id}/test | webhook:write | Sends a test delivery and waits |
GET /api/public/v1/webhooks/{id}/deliveries | webhook:read | A page of its delivery log, newest first |
| Property | Value |
|---|---|
| Org kind | Seller organizations only |
| Cost | 1 token per request |
| Body | JSON, at most 64 KiB, on POST and PATCH of an endpoint; none elsewhere |
webhook:read is in the default scope set; webhook:write must be granted explicitly. Events are emitted by the organization whose calls they describe — the seller side — so a buyer-only organization gets 403.
The endpoint object
| Field | Type | Nullable | Example |
|---|---|---|---|
id | string (uuid) | No | "eeeeeeee-1111-4111-8111-111111111111" |
url | string | No | "https://hooks.example.com/dialcru" |
method | string | No | "POST" |
description | string | Yes | "CRM intake" |
event_type | string | No | "call_outcome.submitted" |
subtypes | array | Yes | ["interested", "callback"] |
campaign_ids | array | Yes | ["7e8f9a0b-1c2d-4e3f-a4b5-c6d7e8f9a0b1"] |
headers | array | No | see below |
body_template | string | No | "{\"status\": {{ call.status | json }}}" |
schedule_template | string | Yes | "{{ call.ended_at }}" |
content_type | string | No | "application/json" |
enabled | boolean | No | true |
disabled_reason | string | Yes | "failures" |
disabled_at | string (ISO) | Yes | "2026-11-02T09:00:00.000Z" |
consecutive_failures | number | No | 0 |
secret_hint | string | No | "…a1B2" |
previous_secret_expires_at | string (ISO) | Yes | "2026-11-03T09:00:00.000Z" |
created_at | string (ISO) | No | "2026-11-01T09:00:00.000Z" |
updated_at | string (ISO) | No | "2026-11-02T09:00:00.000Z" |
event_typeis one event an endpoint subscribes to — see Events for the ids, their subtypes and their payloads.methodisGET,POST,PUT,PATCHorDELETE.subtypesnarrows the event by its subtype dimension (a call's status, an outcome's disposition);campaign_idsby campaign.nullis every — including subtypes and campaigns added later. An event that belongs to no campaign always readscampaign_ids: null.body_template,schedule_templateandcontent_typeare the template the body is rendered from, the optional delivery time, and the body's media type.disabled_reasonis why an endpoint is off:manual,failures(20 in a row) orgone(it answered410). See When we stop sending.secret_hintis the signing secret's last four characters. The secret itself is shown once, when it is made, and never again.previous_secret_expires_atis set after a rotation, while the old secret still signs beside the new one.
Each header:
json
[
{ "id": "…", "name": "X-Source", "secret": false, "value": "dialcru" },
{ "id": "…", "name": "Authorization", "secret": true, "hint": "…f00d" }
]A secret header's value is stored encrypted and never shown again — only its hint. A plain header's value is shown as stored.
The fields are ApiWebhookSchema in src/lib/schema/api/api_webhook.schema.ts; the ids are WEBHOOKS in src/lib/const/webhook.const.ts.
GET /api/public/v1/webhooks
Paged as every list is — see Pagination. Deleted endpoints are not listed.
| Parameter | Meaning |
|---|---|
limit | Rows per page, 1–100, default 50. |
cursor | The previous page's next_cursor. |
event_type | Only endpoints for this event; repeat it for several. |
GET /api/public/v1/webhooks/{id}
One endpoint. Another organization's, a deleted one, or an id that is not an endpoint id at all, is a 404.
POST /api/public/v1/webhooks
json
{
"url": "https://hooks.example.com/dialcru",
"event_type": "call_outcome.submitted",
"description": "CRM intake",
"subtypes": ["interested", "callback"],
"campaign_ids": "all",
"headers": [
{ "name": "Authorization", "value": "Bearer 3f…", "secret": true }
]
}| Field | Required | Default |
|---|---|---|
url | Yes | — |
event_type | Yes | — |
method | No | POST |
description | No | None; up to 255 characters |
subtypes | No | "all"; otherwise a non-empty list of the event's subtypes |
campaign_ids | No | "all"; otherwise a non-empty list of your campaign ids |
headers | No | none; up to 20, each name, value, optional secret |
body_template | No | The event's starter template, as the dashboard pre-fills |
schedule_template | No | none — every delivery sent at once |
content_type | No | application/json |
201 with the endpoint and its signing secret:
json
{ "data": { "id": "…", "secret": "whsec_…", "secret_hint": "…a1B2", "…": "…" } }Store secret now. It is in this response and nowhere else; a lost secret is replaced by rotating it (below), not recovered.
The refusals are the dashboard's, as 400 INVALID_INPUT with a path naming the field: a URL that is not https, or that resolves to a private address; a template that names a field the event does not carry, or does not render; a subtype the event does not have; a campaign id that is not one of yours; a campaign_ids list on an event that belongs to no campaign; a reserved header name. Templates lists the rules.
PATCH /api/public/v1/webhooks/{id}
Send only what changes; every field of POST is accepted, plus enabled. At least one field is required. 200 with the endpoint as it now stands.
enabled:falseswitches the endpoint off (disabled_reason: "manual");trueswitches it back on, whatever switched it off, and clearsconsecutive_failures. Anything queued while it was off was dropped — see Delivery for resending.headersreplaces the whole list. To keep a header, send it back with itsidandnameand novalue: a secret one keeps its stored value without you ever holding it, so this is also how to rename one. Send avalueto change it, and omitidto add a header. A secret header cannot be made plain without sending its value. Leaveheadersout to keep them all.- Changing
event_typeresetssubtypesto"all", since subtypes are per event — unless the same request sendssubtypesfor the new event. The body template is kept, so a template naming fields the new event lacks is refused: send a newbody_templatewith it.campaign_idscarries over, except to an event that belongs to no campaign. descriptionandschedule_template:nullclears them.
The settings are saved first, then enabled: a refused change leaves the endpoint switched as it was. The signing secret is never changed by PATCH.
DELETE /api/public/v1/webhooks/{id}
Deletes the endpoint: deliveries stop at once, and it cannot be restored. 200:
json
{ "data": { "id": "eeeeeeee-1111-4111-8111-111111111111", "deleted": true } }A second DELETE of the same endpoint is a 404. To stop deliveries for a while instead, PATCH it with "enabled": false.
POST /api/public/v1/webhooks/{id}/rotate-secret
No body. 200 with the endpoint and its new secret, shown once. For 24 hours (WEBHOOKS.ROTATION_GRACE_SEC) deliveries are signed with both the old and the new secret, so you can deploy the new one when it suits you — see Signatures.
POST /api/public/v1/webhooks/{id}/test
No body. Renders the endpoint's own template against sample values, sends it, and waits for the answer:
json
{ "data": { "delivered": true } }delivered is whether your endpoint answered as a success, by the endpoint's own response rules. The test is recorded in the delivery log with is_test: true. A switched-off endpoint is a 409.
GET /api/public/v1/webhooks/{id}/deliveries
The endpoint's delivery log, newest first, paged as every list is.
| Parameter | Meaning |
|---|---|
limit | Rows per page, 1–100, default 50. |
cursor | The previous page's next_cursor. |
status | pending, sending, delivered, dead or cancelled; repeatable. |
Each delivery:
| Field | Type | Nullable | Example |
|---|---|---|---|
id | string (uuid) | No | "dddddddd-1111-4111-8111-111111111111" |
event_type | string | No | "call.completed" |
status | string | No | "delivered" |
is_test | boolean | No | false |
occurred_at | string (ISO) | No | "2026-11-02T09:00:00.000Z" |
attempt_count | number | No | 1 |
next_attempt_at | string (ISO) | Yes | null |
scheduled_at | string (ISO) | Yes | null |
schedule_error | string | Yes | null |
render_error | string | Yes | null |
created_at | string (ISO) | No | "2026-11-02T09:00:01.000Z" |
last_attempt | object | Yes | see below |
status dead is a delivery we gave up on. last_attempt is null until the first attempt, then:
| Field | Type | Nullable | Example |
|---|---|---|---|
last_attempt.number | number | No | 1 |
last_attempt.outcome | string | No | "delivered" |
last_attempt.status_code | number | Yes | 200 |
last_attempt.started_at | string (ISO) | No | "2026-11-02T09:00:02.000Z" |
last_attempt.duration_ms | number | Yes | 120 |
last_attempt.error | string | Yes | null |
outcome is delivered, retry, gone, stop or retired. No body is published — neither what we sent nor what your endpoint answered. Either can carry a phone number, and a page of them would be a bulk export; open the delivery in the dashboard to read them. The log is kept for 90 days.
Unknown fields
A body with a field these pages do not name is a 400, not ignored — a misspelt enabeld would otherwise answer 200 having changed nothing.
Rate limits
Beside the per-key request budget, creating, changing, rotating and testing an endpoint spend one token each from the organization's webhook editing budget — 60 a minute (WEBHOOKS.WRITE_RATE_LIMIT), shared with the dashboard, since each of them reaches the endpoint's host. Switching an endpoint on or off and deleting one spend none. A spent budget is a 429 TOO_MANY_REQUESTS naming it.