Webhooks
Generated from
DialerDigital/corecommit6e9f026f0a72— specopenapi/dialer-v1.yamlv0.36.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Outbound webhooks for CRMs: the tenant's endpoint registry, its subscribed events and the signing secret (shown once; rotation with a window).
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /v1/webhooks | Outbound webhook endpoints |
POST | /v1/webhooks | Register an outbound webhook endpoint |
GET | /v1/webhooks/{id} | Fetch one endpoint |
PATCH | /v1/webhooks/{id} | Update url / events / enabled |
DELETE | /v1/webhooks/{id} | Disable an endpoint (rows are never erased) |
POST | /v1/webhooks/{id}/rotate-secret | Rotate the signing secret (24 h overlap window) |
GET | /v1/webhooks/{id}/deliveries | Deliveries of one endpoint, with every attempt |
POST | /v1/webhooks/{id}/deliveries/{delivery_id}/redeliver | Queue one more attempt for a delivery |
Outbound webhook endpoints
GET /v1/webhooksEvery endpoint of the tenant, enabled or not, oldest first. Responses carry secret_hint (the last 4 characters of the CURRENT signing secret) and NEVER the secret. Tenant scope (dd_ key or a dashboard login); a ddw_ widget token is 403.
Responses
200
The tenant's endpoints.
| Field | Type | Required | Description |
|---|---|---|---|
webhooks | array of WebhookEndpoint | yes | |
webhooks[].id | string (uuid) | no | |
webhooks[].url | string | no | |
webhooks[].events | array of WebhookEventName | no | |
webhooks[].enabled | boolean | no | |
webhooks[].disabled_reason | string | null | no | disabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason). |
webhooks[].secret_hint | string | no | Last 4 characters of the current signing secret. |
webhooks[].previous_secret_expires_at | string (date-time) | null | no | End of the rotation window in which the previous secret still verifies; null = no previous secret verifies. |
webhooks[].created_at | string (date-time) | no | |
webhooks[].updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}Register an outbound webhook endpoint
POST /v1/webhooksMints the endpoint's HMAC signing secret and returns it once, as the top-level secret of this response — it is never readable again (reads show secret_hint; POST /v1/webhooks/{id}/rotate-secret mints a new one). The secret is stored envelope-encrypted per tenant.
url MUST be an https:// URL on port 443 whose host resolves only to PUBLIC addresses — an IP literal, loopback, RFC1918, CGNAT, link-local / cloud metadata, .internal/cluster names and a name that does not resolve are refused 400 naming url (SSRF guard, fail-closed); the same check re-runs at delivery time. events is a non-empty subset of the closed v1 catalog (call.ended, call.answered, promise.recorded, sms.received, sms.optout, recording.available); an unknown or repeated event is 400. recording.available is accepted now and emitted once its producer exists.
Nothing is delivered yet: the outbox and the dispatcher are the next phases of MT-CTI-04. tenant_id and secret are NOT request fields — a body carrying either is 400 naming it.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | https:// on 443, public host only (see above). |
events | array of WebhookEventName | yes |
{
"url": "https://crm.example.com/hooks/dialerdigital",
"events": [
"call.ended",
"promise.recorded"
]
}Responses
201
The endpoint, plus the signing secret — the FIRST of the only two times it is shown.
| Field | Type | Required | Description |
|---|---|---|---|
webhook | object (WebhookEndpoint) | yes | One outbound webhook endpoint. NEVER carries the signing secret: secret_hint is the last 4 characters of the CURRENT secret, enough to tell which one the CRM holds. tenant_id is implicit in the bearer. |
webhook.id | string (uuid) | no | |
webhook.url | string | no | |
webhook.events | array of WebhookEventName | no | |
webhook.enabled | boolean | no | |
webhook.disabled_reason | string | null | no | disabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason). |
webhook.secret_hint | string | no | Last 4 characters of the current signing secret. |
webhook.previous_secret_expires_at | string (date-time) | null | no | End of the rotation window in which the previous secret still verifies; null = no previous secret verifies. |
webhook.created_at | string (date-time) | no | |
webhook.updated_at | string (date-time) | no | |
secret | string | yes | whsec_-prefixed HMAC secret. Shown ONCE; store it now. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}Fetch one endpoint
GET /v1/webhooks/{id}Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The endpoint (secret_hint, never the secret).
| Field | Type | Required | Description |
|---|---|---|---|
webhook | object (WebhookEndpoint) | yes | One outbound webhook endpoint. NEVER carries the signing secret: secret_hint is the last 4 characters of the CURRENT secret, enough to tell which one the CRM holds. tenant_id is implicit in the bearer. |
webhook.id | string (uuid) | no | |
webhook.url | string | no | |
webhook.events | array of WebhookEventName | no | |
webhook.enabled | boolean | no | |
webhook.disabled_reason | string | null | no | disabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason). |
webhook.secret_hint | string | no | Last 4 characters of the current signing secret. |
webhook.previous_secret_expires_at | string (date-time) | null | no | End of the rotation window in which the previous secret still verifies; null = no previous secret verifies. |
webhook.created_at | string (date-time) | no | |
webhook.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Update url / events / enabled
PATCH /v1/webhooks/{id}url and events re-run the same validation as the create. enabled: true re-enables an endpoint that DELETE disabled and clears disabled_reason. The secret is NOT a field here — rotation is its own verb, so no write can replace a secret without handing the new one back. Unknown keys are 400.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
url | string | no | |
events | array of WebhookEventName | no | |
enabled | boolean | no |
{
"events": [
"call.ended",
"call.answered",
"sms.received"
]
}Responses
200
The updated endpoint.
| Field | Type | Required | Description |
|---|---|---|---|
webhook | object (WebhookEndpoint) | yes | One outbound webhook endpoint. NEVER carries the signing secret: secret_hint is the last 4 characters of the CURRENT secret, enough to tell which one the CRM holds. tenant_id is implicit in the bearer. |
webhook.id | string (uuid) | no | |
webhook.url | string | no | |
webhook.events | array of WebhookEventName | no | |
webhook.enabled | boolean | no | |
webhook.disabled_reason | string | null | no | disabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason). |
webhook.secret_hint | string | no | Last 4 characters of the current signing secret. |
webhook.previous_secret_expires_at | string (date-time) | null | no | End of the rotation window in which the previous secret still verifies; null = no previous secret verifies. |
webhook.created_at | string (date-time) | no | |
webhook.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Disable an endpoint (rows are never erased)
DELETE /v1/webhooks/{id}Sets enabled: false with disabled_reason: "disabled_by_api" and keeps the row: it is the tenant's evidence of what was subscribed and when, and the app role has no DELETE grant. Idempotent — a second DELETE keeps the original reason. PATCH {"enabled": true} brings it back.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The endpoint, now disabled.
| Field | Type | Required | Description |
|---|---|---|---|
webhook | object (WebhookEndpoint) | yes | One outbound webhook endpoint. NEVER carries the signing secret: secret_hint is the last 4 characters of the CURRENT secret, enough to tell which one the CRM holds. tenant_id is implicit in the bearer. |
webhook.id | string (uuid) | no | |
webhook.url | string | no | |
webhook.events | array of WebhookEventName | no | |
webhook.enabled | boolean | no | |
webhook.disabled_reason | string | null | no | disabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason). |
webhook.secret_hint | string | no | Last 4 characters of the current signing secret. |
webhook.previous_secret_expires_at | string (date-time) | null | no | End of the rotation window in which the previous secret still verifies; null = no previous secret verifies. |
webhook.created_at | string (date-time) | no | |
webhook.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Rotate the signing secret (24 h overlap window)
POST /v1/webhooks/{id}/rotate-secretMints a new secret and returns it once (top-level secret). The previous secret keeps verifying until previous_secret_expires_at (24 h): deliveries in that window are signed with the new secret and carry a second signature with the previous one, so a receiver that has not switched yet keeps verifying. Only the last two secrets ever verify — a rotation inside an open window replaces the previous one. No request body.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The endpoint (new secret_hint, window open) plus the NEW secret — the second and last time a secret is shown.
| Field | Type | Required | Description |
|---|---|---|---|
webhook | object (WebhookEndpoint) | yes | One outbound webhook endpoint. NEVER carries the signing secret: secret_hint is the last 4 characters of the CURRENT secret, enough to tell which one the CRM holds. tenant_id is implicit in the bearer. |
webhook.id | string (uuid) | no | |
webhook.url | string | no | |
webhook.events | array of WebhookEventName | no | |
webhook.enabled | boolean | no | |
webhook.disabled_reason | string | null | no | disabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason). |
webhook.secret_hint | string | no | Last 4 characters of the current signing secret. |
webhook.previous_secret_expires_at | string (date-time) | null | no | End of the rotation window in which the previous secret still verifies; null = no previous secret verifies. |
webhook.created_at | string (date-time) | no | |
webhook.updated_at | string (date-time) | no | |
secret | string | yes | The NEW whsec_ secret. Shown ONCE. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Deliveries of one endpoint, with every attempt
GET /v1/webhooks/{id}/deliveriesThe append-only evidence of what this endpoint was sent and what came back — newest first, keyset-paginated. A tenant disputing a CRM ("we never got it") answers with this: each delivery carries its attempts in order, and each attempt the HTTP status_code, the first ≤ 1 KiB of the response BODY (response_excerpt, never headers) and the instants the request started and finished.
status is DERIVED from the attempts, not stored: pending when no attempt was made yet, otherwise the outcome of the LAST one (retry, delivered, deadletter, endpoint_disabled). The event envelope is NOT part of a row — data is the public /v1 projection of the resource and is readable from the resource's own GET.
Filters: event (one name of the v1 catalog), status (one of the five above), since (RFC-3339, inclusive lower bound on the delivery's created_at). An unknown value for event or status is 400 naming the accepted set — never a silently empty page. Tenant scope (dd_ key or a dashboard login); a ddw_ widget token is 403, and an endpoint of another tenant is 404.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
event | query | call.ended · call.answered · promise.recorded · sms.received · sms.optout · recording.available | no | |
status | query | pending · retry · delivered · deadletter · endpoint_disabled | no | |
since | query | string (date-time) | no | Only deliveries created at or after this instant. |
limit | query | integer | no | |
cursor | query | string | no | Opaque keyset cursor — the next_cursor of the previous page. |
Responses
200
One page of deliveries, newest first.
| Field | Type | Required | Description |
|---|---|---|---|
deliveries | array of WebhookDelivery | yes | |
deliveries[].id | string (uuid) | no | |
deliveries[].endpoint_id | string (uuid) | no | |
deliveries[].event | call.ended · call.answered · promise.recorded · sms.received · sms.optout · recording.available | no | The closed v1 catalog of subscribable events (ADR |
deliveries[].event_id | string | no | Idempotency key the receiver dedupes on; unique per endpoint. |
deliveries[].occurred_at | string (date-time) | no | When the FACT happened (not when it was sent). |
deliveries[].created_at | string (date-time) | no | When the delivery was written, in the fact's own transaction. |
deliveries[].status | pending · retry · delivered · deadletter · endpoint_disabled | no | DERIVED, never stored: pending when the delivery has no attempt yet, otherwise the outcome of its LAST attempt. |
deliveries[].attempts | array of WebhookDeliveryAttempt | no | Every attempt, oldest first. Empty while the delivery is pending. |
deliveries[].attempts[].attempt | integer | no | |
deliveries[].attempts[].outcome | retry · delivered · deadletter · endpoint_disabled | no | |
deliveries[].attempts[].reason | string | no | Closed vocabulary: ok, egress_refused, endpoint_gone, endpoint_not_enabled, payload_too_large, unexpected_status, timeout, transport, protocol, body_too_large, exception, and manual_redelivery — the only one no send produced: the operator asked for one via the redeliver endpoint. |
deliveries[].attempts[].status_code | integer | null | no | The receiver's HTTP status; null when no response arrived. |
deliveries[].attempts[].started_at | string (date-time) | no | |
deliveries[].attempts[].finished_at | string (date-time) | no | |
deliveries[].attempts[].next_attempt_at | string (date-time) | null | no | When the next attempt is due; non-null only on retry. |
deliveries[].attempts[].response_excerpt | string | null | no | First bytes of the response BODY (≤ 1 KiB). Never headers. |
next_cursor | string | null | yes | Cursor of the next page; null on the last one. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Queue one more attempt for a delivery
POST /v1/webhooks/{id}/deliveries/{delivery_id}/redeliverAPPENDS a retry attempt due now (reason: "manual_redelivery") to the delivery's stream; the dispatcher's next tick sends it. Nothing already recorded is edited — the attempts are append-only in the database itself, so the evidence a dispute rests on cannot be rewritten, not even by the platform.
202, not 200: the send happens on the next dispatcher tick, and its result appears as the NEXT attempt in GET /v1/webhooks/{id}/deliveries. The response carries the attempt that was queued.
The numbering continues, so a delivery that already exhausted the retry ladder gets exactly ONE more send: a failure after that is recorded as deadletter, not as another retry.
A disabled endpoint is 409 naming why it was disabled — the 410 Gone its receiver answered (endpoint_gone_410) or the API call (disabled_by_api); re-enable it with PATCH {"enabled": true} first. 409 as well if another attempt was appended concurrently (two redeliveries racing, or one racing the dispatcher): read the delivery back before asking again. No request body.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
delivery_id | path | string (uuid) | yes |
Responses
202
The attempt queued for the dispatcher's next tick.
| Field | Type | Required | Description |
|---|---|---|---|
delivery_id | string (uuid) | yes | |
status | retry | yes | The delivery's derived status after the append. |
attempt | object (WebhookDeliveryAttempt) | yes | One attempt at sending a delivery. Append-only — never edited, never erased. |
attempt.attempt | integer | no | |
attempt.outcome | retry · delivered · deadletter · endpoint_disabled | no | |
attempt.reason | string | no | Closed vocabulary: ok, egress_refused, endpoint_gone, endpoint_not_enabled, payload_too_large, unexpected_status, timeout, transport, protocol, body_too_large, exception, and manual_redelivery — the only one no send produced: the operator asked for one via the redeliver endpoint. |
attempt.status_code | integer | null | no | The receiver's HTTP status; null when no response arrived. |
attempt.started_at | string (date-time) | no | |
attempt.finished_at | string (date-time) | no | |
attempt.next_attempt_at | string (date-time) | null | no | When the next attempt is due; non-null only on retry. |
attempt.response_excerpt | string | null | no | First bytes of the response BODY (≤ 1 KiB). Never headers. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}