Skip to content

Webhooks ​

Generated from DialerDigital/core commit 0fc18008b3c8 — spec openapi/dialer-v1.yaml v0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.

Español

Outbound webhooks for CRMs: the tenant's endpoint registry, its subscribed events and the signing secret (shown once; rotation with a window).

Endpoints ​

MethodPathSummary
GET/v1/webhooksOutbound webhook endpoints
POST/v1/webhooksRegister 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-secretRotate the signing secret (24 h overlap window)
GET/v1/webhooks/{id}/deliveriesDeliveries of one endpoint, with every attempt
POST/v1/webhooks/{id}/deliveries/{delivery_id}/redeliverQueue one more attempt for a delivery

Outbound webhook endpoints ​

http
GET /v1/webhooks

Every 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.

FieldTypeRequiredDescription
webhooksarray of WebhookEndpointyes
webhooks[].idstring (uuid)no
webhooks[].urlstringno
webhooks[].eventsarray of WebhookEventNameno
webhooks[].enabledbooleanno
webhooks[].disabled_reasonstring | nullnodisabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason).
webhooks[].secret_hintstringnoLast 4 characters of the current signing secret.
webhooks[].previous_secret_expires_atstring (date-time) | nullnoEnd of the rotation window in which the previous secret still verifies; null = no previous secret verifies.
webhooks[].created_atstring (date-time)no
webhooks[].updated_atstring (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/webhooks

Mints 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)

FieldTypeRequiredDescription
urlstringyeshttps:// on 443, public host only (see above).
eventsarray of WebhookEventNameyes
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.

FieldTypeRequiredDescription
webhookobject (WebhookEndpoint)yesOne 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.idstring (uuid)no
webhook.urlstringno
webhook.eventsarray of WebhookEventNameno
webhook.enabledbooleanno
webhook.disabled_reasonstring | nullnodisabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason).
webhook.secret_hintstringnoLast 4 characters of the current signing secret.
webhook.previous_secret_expires_atstring (date-time) | nullnoEnd of the rotation window in which the previous secret still verifies; null = no previous secret verifies.
webhook.created_atstring (date-time)no
webhook.updated_atstring (date-time)no
secretstringyeswhsec_-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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The endpoint (secret_hint, never the secret).

FieldTypeRequiredDescription
webhookobject (WebhookEndpoint)yesOne 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.idstring (uuid)no
webhook.urlstringno
webhook.eventsarray of WebhookEventNameno
webhook.enabledbooleanno
webhook.disabled_reasonstring | nullnodisabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason).
webhook.secret_hintstringnoLast 4 characters of the current signing secret.
webhook.previous_secret_expires_atstring (date-time) | nullnoEnd of the rotation window in which the previous secret still verifies; null = no previous secret verifies.
webhook.created_atstring (date-time)no
webhook.updated_atstring (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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
urlstringno
eventsarray of WebhookEventNameno
enabledbooleanno
json
{
  "events": [
    "call.ended",
    "call.answered",
    "sms.received"
  ]
}

Responses

200 ​

The updated endpoint.

FieldTypeRequiredDescription
webhookobject (WebhookEndpoint)yesOne 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.idstring (uuid)no
webhook.urlstringno
webhook.eventsarray of WebhookEventNameno
webhook.enabledbooleanno
webhook.disabled_reasonstring | nullnodisabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason).
webhook.secret_hintstringnoLast 4 characters of the current signing secret.
webhook.previous_secret_expires_atstring (date-time) | nullnoEnd of the rotation window in which the previous secret still verifies; null = no previous secret verifies.
webhook.created_atstring (date-time)no
webhook.updated_atstring (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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200 ​

The endpoint, now disabled.

FieldTypeRequiredDescription
webhookobject (WebhookEndpoint)yesOne 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.idstring (uuid)no
webhook.urlstringno
webhook.eventsarray of WebhookEventNameno
webhook.enabledbooleanno
webhook.disabled_reasonstring | nullnodisabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason).
webhook.secret_hintstringnoLast 4 characters of the current signing secret.
webhook.previous_secret_expires_atstring (date-time) | nullnoEnd of the rotation window in which the previous secret still verifies; null = no previous secret verifies.
webhook.created_atstring (date-time)no
webhook.updated_atstring (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-secret

Mints 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource 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.

FieldTypeRequiredDescription
webhookobject (WebhookEndpoint)yesOne 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.idstring (uuid)no
webhook.urlstringno
webhook.eventsarray of WebhookEventNameno
webhook.enabledbooleanno
webhook.disabled_reasonstring | nullnodisabled_by_api after a DELETE; null while enabled or after PATCH {"enabled": false} (that path records no reason).
webhook.secret_hintstringnoLast 4 characters of the current signing secret.
webhook.previous_secret_expires_atstring (date-time) | nullnoEnd of the rotation window in which the previous secret still verifies; null = no previous secret verifies.
webhook.created_atstring (date-time)no
webhook.updated_atstring (date-time)no
secretstringyesThe 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}/deliveries

The 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
eventquerycall.ended · call.answered · promise.recorded · sms.received · sms.optout · recording.available · disposition.event · carrier.health_changed · anomaly.detected · ai.drift.detectedno
statusquerypending · retry · delivered · deadletter · endpoint_disabledno
sincequerystring (date-time)noOnly deliveries created at or after this instant.
limitqueryintegerno
cursorquerystringnoOpaque keyset cursor — the next_cursor of the previous page.

Responses

200 ​

One page of deliveries, newest first.

FieldTypeRequiredDescription
deliveriesarray of WebhookDeliveryyes
deliveries[].idstring (uuid)no
deliveries[].endpoint_idstring (uuid)no
deliveries[].eventcall.ended · call.answered · promise.recorded · sms.received · sms.optout · recording.available · disposition.event · carrier.health_changed · anomaly.detected · ai.drift.detectednoThe closed v1 catalog of subscribable events (ADR #121).
deliveries[].event_idstringnoIdempotency key the receiver dedupes on; unique per endpoint.
deliveries[].occurred_atstring (date-time)noWhen the FACT happened (not when it was sent).
deliveries[].created_atstring (date-time)noWhen the delivery was written, in the fact's own transaction.
deliveries[].statuspending · retry · delivered · deadletter · endpoint_disablednoDERIVED, never stored: pending when the delivery has no attempt yet, otherwise the outcome of its LAST attempt.
deliveries[].attemptsarray of WebhookDeliveryAttemptnoEvery attempt, oldest first. Empty while the delivery is pending.
deliveries[].attempts[].attemptintegerno
deliveries[].attempts[].outcomeretry · delivered · deadletter · endpoint_disabledno
deliveries[].attempts[].reasonstringnoClosed 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_codeinteger | nullnoThe receiver's HTTP status; null when no response arrived.
deliveries[].attempts[].started_atstring (date-time)no
deliveries[].attempts[].finished_atstring (date-time)no
deliveries[].attempts[].next_attempt_atstring (date-time) | nullnoWhen the next attempt is due; non-null only on retry.
deliveries[].attempts[].response_excerptstring | nullnoFirst bytes of the response BODY (≤ 1 KiB). Never headers.
next_cursorstring | nullyesCursor 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}/redeliver

APPENDS 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
delivery_idpathstring (uuid)yes

Responses

202 ​

The attempt queued for the dispatcher's next tick.

FieldTypeRequiredDescription
delivery_idstring (uuid)yes
statusretryyesThe delivery's derived status after the append.
attemptobject (WebhookDeliveryAttempt)yesOne attempt at sending a delivery. Append-only — never edited, never erased.
attempt.attemptintegerno
attempt.outcomeretry · delivered · deadletter · endpoint_disabledno
attempt.reasonstringnoClosed 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_codeinteger | nullnoThe receiver's HTTP status; null when no response arrived.
attempt.started_atstring (date-time)no
attempt.finished_atstring (date-time)no
attempt.next_attempt_atstring (date-time) | nullnoWhen the next attempt is due; non-null only on retry.
attempt.response_excerptstring | nullnoFirst 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"
  }
}

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.