Skip to content

Outbound webhooks (CRM integration)

Outbound webhooks push platform facts to your server: when a call ends, when an agent records a promise to pay, when a consumer replies by SMS or opts out, when a recording becomes fetchable. You register an HTTPS endpoint, subscribe it to the events you care about, and the platform POSTs a small signed JSON envelope to it — no polling, no long-lived socket.

Availability: built in core, not in a published release yet

This page documents the contract as implemented in core's main (MT-CTI-04, phases 1–4, ADRs #121, #122 and #123 with its phase-4 addendum). The newest published core release at the time of writing is v0.35.0, whose OpenAPI spec does not carry the /v1/webhooks surface — so this surface is not in the API Reference either, which is generated from the vendored copy of that release's spec. Confirm with your operator that the version running in your environment serves /v1/webhooks before you build against it. When the surface reaches a release, the generated reference becomes the authority on request and response shapes and this page stays the integration guide.

Not the same thing as the SMS provider webhooks

Webhooks in the API Reference are endpoints we host and your SMS provider calls (delivery receipts, consumer replies). These are the opposite direction: we call you. The two use different signature schemes, so do not reuse a verifier between them — the inbound SMS one is a bare hex digest in x-dd-signature, the one below is a timestamped t=…,v1=… value.

The shape of an integration

1. POST /v1/webhooks            → endpoint registered, signing secret shown ONCE
2. … a call ends, a promise is recorded, an SMS arrives …
3. POST https://crm.example.com/hooks/dialerdigital
      X-DD-Event: call.ended
      X-DD-Delivery-Id: <uuid>          ← your idempotency key
      X-DD-Signature: t=…,v1=…          ← verify this before parsing
      {"event":"call.ended","event_id":"…","occurred_at":"…","tenant_id":"…","data":{…}}
4. you answer 2xx  → delivered
   anything else   → retried on a capped ladder, then dead-lettered
   410 Gone        → the endpoint is disabled and nothing else is sent

Step 1 — register an endpoint

bash
curl -s -X POST $API/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://crm.example.com/hooks/dialerdigital",
    "events": ["call.ended", "promise.recorded"]
  }'
json
{
  "webhook": {
    "id": "…",
    "url": "https://crm.example.com/hooks/dialerdigital",
    "events": ["call.ended", "promise.recorded"],
    "enabled": true,
    "disabled_reason": null,
    "secret_hint": "…",
    "previous_secret_expires_at": null
  },
  "secret": "whsec_…"
}

secret is the HMAC signing key and it is shown once, here. It is never readable again: every later read shows secret_hint, the last 4 characters of the current secret — enough to tell which key you hold, useless for forging a signature. Store it in your secret manager before you close the response.

What the url must be, or the call is 400 naming url: https:// on port 443, with a host that resolves only to public addresses. An IP literal, loopback, RFC1918, CGNAT, link-local or cloud-metadata address, an .internal/cluster name, or a name that does not resolve at all are all refused, fail-closed — and the same check re-runs at delivery time, because DNS is mutable and a name that resolved publicly yesterday can point at a metadata address today.

events is a non-empty, duplicate-free subset of the closed v1 catalog below; an unknown or repeated name is 400. tenant_id and secret are not request fields — the bearer key is the tenant, and a body carrying either is 400 naming it.

Endpoints are managed with the rest of the surface: GET /v1/webhooks, GET /v1/webhooks/{id}, PATCH /v1/webhooks/{id} (url / events / enabled), and DELETE /v1/webhooks/{id}, which does not erase the row — it sets enabled: false with disabled_reason: "disabled_by_api" and keeps the record of what was subscribed and when. PATCH {"enabled": true} brings it back.

Step 2 — the v1 envelope

Every delivery body is the same five-field envelope, serialized at the moment of the fact, inside the transaction that persisted it, and never rebuilt afterwards:

json
{
  "event": "promise.recorded",
  "event_id": "019968d2-6b1e-7c4a-9b0e-3f2a1c5d7e90",
  "occurred_at": "2026-09-17T20:15:30.123456Z",
  "tenant_id": "5a1c3e7d-2f4b-4c8e-9d1a-0b2c3d4e5f60",
  "data": { "…": "the public /v1 projection of the resource" }
}
FieldWhat it is
eventOne name of the v1 catalog. Also repeated in the X-DD-Event header, so you can route without parsing.
event_idThe id of the row the event announces. Unique per endpoint — see idempotency.
occurred_atWhen the fact happened (the call's ended_at, the message's occurred_at, …), not when it was sent. A redelivery two hours later carries the same occurred_at.
tenant_idYour tenant. Useful when one receiver serves several workspaces.
dataThe public projection of the resource — byte for byte the shape that resource's own GET answers, so a receiver that already parses /v1 responses reuses its parser.

The v1 event catalog

The catalog is closed — these six names. A subscription to anything else is rejected twice over: by the API on the way in, and by a CHECK constraint on the events column itself.

Eventdataevent_id is
call.endedCallAttempt — the finalize correction rowthe correction row's id
call.answeredCallAttempt — the original row, with answered_at<original attempt id>:answered (an answer has no durable row of its own)
promise.recordedPromisethe promise's id
sms.receivedSmsMessage (list projection, 40-character body_preview)the ledger row's id
sms.optoutSmsMessage (same)the ledger row's id
recording.availableRecordingthe recording's id

Each has a transactional producer, so the announcement commits — or rolls back — with the fact itself. There is no "we wrote the row but forgot to tell you" window, and no event is emitted for a fact that was rolled back.

Step 3 — verify the signature

Every delivery carries:

X-DD-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 over "<t>.<raw body>">

The timestamp is inside the signed string, so a captured request cannot be replayed later with a fresh t. Verify in this order, and do all of it before you parse the JSON:

  1. Parse t and v1 out of the header. Anything that does not parse is a rejection, not a warning.
  2. Check t against your clock with a tolerance — the platform's own reference implementation uses 5 minutes, in either direction so that a receiver clock running slightly ahead does not refuse everything.
  3. Recompute HMAC-SHA256(secret, "<t>." + raw_body) over the raw bytes exactly as received. Re-serializing the JSON — key order, whitespace, number formatting — changes the bytes and breaks the MAC.
  4. Compare in constant time. A byte-by-byte early-exit comparison leaks the expected digest to anyone who can time your responses.

Node.js

js
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => {
      const i = p.indexOf('=')
      return i === -1 ? [p, ''] : [p.slice(0, i), p.slice(i + 1)]
    })
  )
  const t = Number.parseInt(parts.t ?? '', 10)
  const provided = parts.v1 ?? ''
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(provided)) return 'malformed'
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return 'stale'

  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex')
  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(provided, 'utf8')
  return a.length === b.length && timingSafeEqual(a, b) ? 'ok' : 'mismatch'
}

rawBody must be the request body as a Buffer or string captured before any JSON middleware touched it. Most Node frameworks parse the body for you by default and hand your handler an object, not bytes — reach for whatever raw-body hook yours offers, and check that re-encoding the object is not what you end up signing over.

Python

python
import hashlib, hmac, re, time

_HEX64 = re.compile(r"^[0-9a-f]{64}$")


def verify(secret: bytes, raw_body: bytes, header: str, tolerance_seconds: int = 300) -> str:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        provided = parts["v1"]
    except (KeyError, ValueError):
        return "malformed"
    if not _HEX64.match(provided):
        return "malformed"
    if abs(int(time.time()) - t) > tolerance_seconds:
        return "stale"

    expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return "ok" if hmac.compare_digest(expected, provided) else "mismatch"

Same requirement: raw_body is the untouched request bytes, read before anything decodes them.

Both return the same four outcomes the platform's own verifier does — ok, malformed, stale, mismatch — so a log line on one side means the same thing on the other. Only ok may reach your business logic; everything else is a rejected request.

Answering

Answer 2xx and answer fast: the whole request is given 15 seconds (5 of them for the connect) before it counts as a failure and gets retried. Do the verification inline, enqueue the work, and return — do not do the CRM write on the request thread.

Your response body is read up to 64 KiB and the first 1 KiB is stored as evidence (response_excerpt) — never your response headers. An ack is a few bytes; there is nothing to gain by returning more.

Step 4 — rotating the signing secret

bash
curl -s -X POST $API/v1/webhooks/$ID/rotate-secret -H "Authorization: Bearer $TOKEN"

Rotation mints a new secret and returns it once — the second and last time a secret is ever shown. The previous secret keeps verifying until previous_secret_expires_at, which is 24 hours after the rotation by default. While that window is open, every delivery carries two headers:

X-DD-Signature:          t=…,v1=…   ← signed with the NEW secret
X-DD-Signature-Previous: t=…,v1=…   ← same construction, PREVIOUS secret

So a zero-downtime rotation is: rotate → deploy the new secret to your receiver at your own pace → done. A receiver still holding the old key verifies X-DD-Signature-Previous; one that has switched verifies X-DD-Signature and ignores the second header. Only ever two secrets verify at once — rotating again inside an open window replaces the previous one, it does not stack a third. Once the window closes the old secret is never used to sign again.

If you accept both headers during a migration, accept a delivery when either verifies — and go back to accepting only X-DD-Signature once you have switched, so an expired key cannot be a second valid credential in your code forever.

Step 5 — retries and idempotency

What is retried

Your answerWhat happens
2xxdelivered. Done.
410 GoneThe endpoint is disabled (disabled_reason: "endpoint_gone_410") and nothing more is sent to it. This is the deliberate "stop, this URL is dead" signal — re-enable with PATCH {"enabled": true} once the URL is back.
any other status, a timeout, a transport or TLS error, a response body over the read capA failure — retried.

There is no "only 5xx is retried" rule: a 404 or a 403 from your side is a failure like any other, because from here it is indistinguishable from a misconfigured proxy. 410 is the one status with a meaning of its own.

The ladder

Delivery is attempted up to 5 times by default. After a failure, the next attempt is due 1 m, 5 m, 30 m and 2 h later respectively — the ladder is exponential and capped, never unbounded, and it is configurable per deployment (WEBHOOKS_MAX_ATTEMPTS / WEBHOOKS_BACKOFF_SECONDS), so ask your operator for the values in force if you are sizing an outage budget. After the last attempt fails the delivery is dead-lettered: it stops being retried and stays in the evidence stream with its failure reason.

Two more refusals happen without any request at all, so they cost you nothing: an envelope over 256 KiB is dead-lettered as payload_too_large immediately (a retry cannot shrink it), and a URL whose host no longer resolves publicly is refused by the egress guard at send time.

Deduplicate — a delivery can repeat

X-DD-Delivery-Id: <uuid>

Delivery is at-least-once. A worker that dies mid-batch rolls back, and its deliveries are attempted again; a network failure after your server committed but before the response reached us is retried; an operator can ask for a redelivery by hand. So the same delivery can arrive more than once, and the receiver's idempotency key is X-DD-Delivery-Id.

Make it a unique column and let the insert conflict do the work:

sql
CREATE TABLE dialerdigital_deliveries (
  delivery_id uuid PRIMARY KEY,
  event       text        NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);
-- per delivery, inside the transaction that applies the effect:
INSERT INTO dialerdigital_deliveries (delivery_id, event) VALUES ($1, $2)
ON CONFLICT (delivery_id) DO NOTHING;
-- 0 rows affected ⇒ already applied ⇒ answer 2xx and do nothing else

event_id is the second, coarser key: it identifies the fact, is unique per endpoint, and is stable across deliveries of that fact. Dedupe on X-DD-Delivery-Id to collapse repeats of one delivery; dedupe on event_id if you want to be sure one fact is applied once even across a re-subscription. Exactly-once is your key, not our promise.

What is not promised

  • No ordering. Nothing guarantees the order deliveries arrive in — not across events, not across endpoints, and not between two events of the same call. Each delivery carries its own retry ladder, so one failed attempt is enough to invert a pair: if a call.answered fails and is due again in a minute, the call.ended enqueued after it is sent first. Several dispatcher workers drain the outbox concurrently, which is the second way a pair reorders. Order your own state machine on occurred_at and on the data fields, never on arrival order.
  • At-least-once, not exactly-once. See above; dedupe.
  • Not a queue you can replay at will. The evidence stream is append-only and readable, and a single delivery can be re-queued by hand, but there is no bulk "replay the last hour" verb.
  • The catalog is closed at six events. Anything else you need is a change to the platform, not a subscription you can express today.

Evidence: what was sent, and what came back

bash
curl -s "$API/v1/webhooks/$ID/deliveries?status=deadletter&limit=50" \
  -H "Authorization: Bearer $TOKEN"

GET /v1/webhooks/{id}/deliveries is the append-only record of every delivery to that endpoint, newest first, keyset-paginated (limit up to 200, default 50; follow next_cursor until it is null). Filters: event, status, and since (an inclusive lower bound on the delivery's creation, RFC 3339). An unknown event or status value is 400 naming the accepted set — never a silently empty page that reads as "nothing happened".

Each delivery carries its attempts in order, and each attempt the HTTP status_code, the response_excerpt, and the instants the request started and finished. status is derived from the attempts, not stored: pending while no attempt has been made, otherwise the outcome of the last one — retry, delivered, deadletter or endpoint_disabled.

This is the answer to "we never received it". It is append-only in the database itself, so nothing already recorded can be edited — not by you, not by the platform.

Asking for one more attempt

bash
curl -s -X POST "$API/v1/webhooks/$ID/deliveries/$DELIVERY_ID/redeliver" \
  -H "Authorization: Bearer $TOKEN"

202, not 200: this appends a retry attempt due now and the dispatcher sends it on its next tick; the result shows up as the next attempt in the deliveries list. The numbering continues, so a delivery that already exhausted the 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 (endpoint_gone_410 or disabled_by_api); re-enable it first. It is also 409 if another attempt was appended concurrently — two redeliveries racing, or one racing the dispatcher. Read the delivery back before asking again.

Limits at a glance

Envelope size≤ 256 KiB (over that: dead-lettered, never sent)
Connect timeout5 s
Whole request15 s
Response read≤ 64 KiB; first 1 KiB stored as evidence
Signature tolerance5 minutes, either direction (reference implementation)
Rotation window24 h by default
Attempts5 by default, then dead-letter
Deliveries pagelimit 1–200, default 50

Scope of the credential

The webhook surface is tenant-scoped: a dd_ API key or a dashboard login. A ddw_ widget token is 403 — an agent's browser session cannot read or change where a workspace's events are pushed. An endpoint belonging to another tenant reads as 404, never 403: existence is not leaked.

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