Appearance
Webhooks
Generated from
DialerDigital/corecommit0fc18008b3c8— specopenapi/dialer-v1.yamlv0.134.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
http
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Register an outbound webhook endpoint
http
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.
Limits (422 unprocessable). A tenant may hold at most 20 enabled endpoints (every event writes one delivery row per endpoint); the catalog key webhook_max_endpoints raises it per tenant. Disabled endpoints do not count, and a tenant already above the cap keeps delivering to the ones it has.
url MUST be an https:// URL on port 443 whose host resolves only to PUBLIC addresses — a private IP literal, loopback, RFC1918, CGNAT, link-local / cloud metadata and a name that does not resolve are refused 400 naming url (SSRF guard, fail-closed). The guard judges the resolved addresses only: a .internal or cluster name is refused because of where it resolves, never because of its suffix. 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, disposition.event, carrier.health_changed, anomaly.detected, ai.drift.detected); 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.
Role. Writing the registry needs a dd_ key or a dashboard login with the account_admin role. A member login is 403 role_forbidden (before the body is validated; nothing is written) once the tenant's catalog key webhook_admin_role_enforced is on. Born off: until then a member writes as before and the platform only counts it (dialer.webhooks.admin_role_would_deny).
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | https:// on 443, public host only (see above). |
events | array of WebhookEventName | yes |
json
{
"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).
json
{
"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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Fetch one endpoint
http
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"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).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Update url / events / enabled
http
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.
enabled: true counts as a creation: past webhook_max_endpoints enabled endpoints it is 422.
Role. Writing the registry needs a dd_ key or a dashboard login with the account_admin role. A member login is 403 role_forbidden (before the body is validated; nothing is written) once the tenant's catalog key webhook_admin_role_enforced is on. Born off: until then a member writes as before and the platform only counts it (dialer.webhooks.admin_role_would_deny).
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 |
json
{
"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).
json
{
"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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"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).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Disable an endpoint (rows are never erased)
http
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.
Role. Writing the registry needs a dd_ key or a dashboard login with the account_admin role. A member login is 403 role_forbidden (before the body is validated; nothing is written) once the tenant's catalog key webhook_admin_role_enforced is on. Born off: until then a member writes as before and the platform only counts it (dialer.webhooks.admin_role_would_deny).
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"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).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Rotate the signing secret (24 h overlap window)
http
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.
Role. Writing the registry needs a dd_ key or a dashboard login with the account_admin role. A member login is 403 role_forbidden (before the body is validated; nothing is written) once the tenant's catalog key webhook_admin_role_enforced is on. Born off: until then a member writes as before and the platform only counts it (dialer.webhooks.admin_role_would_deny).
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"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).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Deliveries of one endpoint, with every attempt
http
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 · disposition.event · carrier.health_changed · anomaly.detected · ai.drift.detected | 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 · disposition.event · carrier.health_changed · anomaly.detected · ai.drift.detected | no | The closed v1 catalog of subscribable events (ADR #121). |
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 two no send produced: manual_redelivery (the operator asked for one via the redeliver endpoint) and endpoint_circuit_open (the endpoint's circuit breaker was open, so the delivery was put back for when it ends; it does not count as an attempt). |
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).
json
{
"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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"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).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Queue one more attempt for a delivery
http
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 two no send produced: manual_redelivery (the operator asked for one via the redeliver endpoint) and endpoint_circuit_open (the endpoint's circuit breaker was open, so the delivery was put back for when it ends; it does not count as an attempt). |
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"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).
json
{
"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.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}