Appearance
Webhooks
Generated from
DialerDigital/corecommitbbb8e373d900— specopenapi/dialer-v1.yamlv0.151.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}/test | Send one test event to the endpoint |
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 (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. 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 (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, port_request.ported); 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. 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).
A test API key is refused with 403 test_environment: this verb sets the secret or the destination of events of a live endpoint, and the row is left untouched.
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 (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. 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 (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 (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. 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 (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).
A test API key is refused with 403 test_environment: this verb sets the secret or the destination of events of a live endpoint, and the row is left untouched.
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 (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. 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 (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 (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. 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 (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).
A test API key is refused with 403 test_environment: this verb sets the secret or the destination of events of a live endpoint, and the row is left untouched.
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 (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. 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 (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 · port_request.ported | 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 · port_request.ported · webhook.test | no | An event a delivery can carry: the closed catalog (WebhookEventName) plus the system event webhook.test, which only POST /v1/webhooks/{id}/test appends (nobody subscribes to it). |
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 (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. 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 (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"
}
}Send one test event to the endpoint
http
POST /v1/webhooks/{id}/testSends ONE webhook.test to the endpoint now and answers with what it replied. The request is the one production sends — same signature (x-dd-signature, plus x-dd-signature-previous inside a rotation window), same headers, same egress guard — so a receiver that verifies this verifies the real events. The body is the v1 envelope with event: "webhook.test" and a minimal synthetic data: {"test": true, "webhook_id": …, "sent_at": …}. webhook.test is a system event outside the subscribable catalog: it is never a real event with invented data, so a consumer cannot act on it by mistake.
The test is evidence: the delivery and its single attempt are appended to GET /v1/webhooks/{id}/deliveries (event: "webhook.test"). It is judged once — no retries, no circuit breaker — never counts in the attempt counters, and cannot be redelivered (404 on …/redeliver). A non-2xx answer or a transport failure is still 200 here: the outcome is in status (delivered | deadletter) and attempt.reason.
Limit. At most 5 tests per webhook per minute; the sixth is 429 rate_limited with Retry-After. A disabled endpoint is 409.
Role. Same as the rest of the registry: a dd_ key or a dashboard login with the account_admin role; a member is 403 role_forbidden once the tenant's catalog key webhook_admin_role_enforced is on. 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 attempt that was made.
| Field | Type | Required | Description |
|---|---|---|---|
delivery_id | string (uuid) | yes | |
event | webhook.test | yes | |
status | delivered · deadletter | yes | |
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. |
latency_ms | integer | yes |
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 (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. 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
rate_limited: more than 5 tests of this webhook in the last minute. Wait Retry-After seconds. The key's own quota (ApiKeyRateLimited) can also answer 429 here.
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 (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. 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 (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"
}
}