Skip to content

Webhooks

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

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

Endpoints

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

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

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.

url MUST be an https:// URL on port 443 whose host resolves only to PUBLIC addresses — an IP literal, loopback, RFC1918, CGNAT, link-local / cloud metadata, .internal/cluster names and a name that does not resolve are refused 400 naming url (SSRF guard, fail-closed); the same check re-runs at delivery time. events is a non-empty subset of the closed v1 catalog (call.ended, call.answered, promise.recorded, sms.received, sms.optout, recording.available); an unknown or repeated event is 400. recording.available is accepted now and emitted once its producer exists.

Nothing is delivered yet: the outbox and the dispatcher are the next phases of MT-CTI-04. tenant_id and secret are NOT request fields — a body carrying either is 400 naming it.

Request body (JSON, required)

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

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

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

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

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"
  }
}

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.

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

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

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"
  }
}

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.

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

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

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"
  }
}

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.

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

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

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"
  }
}

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.availableno
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.availablenoThe closed v1 catalog of subscribable events (ADR
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 manual_redelivery — the only one no send produced: the operator asked for one via the redeliver endpoint.
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).

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

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"
  }
}

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 manual_redelivery — the only one no send produced: the operator asked for one via the redeliver endpoint.
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).

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

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"
  }
}

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