Skip to navigation

Webhooks

View as Markdown

A webhook subscription delivers your account’s events to an HTTPS endpoint you control. Register a URL and the event types you care about, and Dial POSTs each matching event to that URL — signed, retried, and idempotency-keyed.

Webhooks are the at-least-once, off-machine counterpart to the presence-based event stream: instead of holding a connection open with dial wait-for, your server receives events as they happen.

Create a subscription

Create one from the dashboard Webhooks page, or via the API:

curl -X POST https://api.getdial.ai/v1/webhooks \
-H "Authorization: Bearer $DIAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "targetUrl": "https://example.com/dial/webhook", "eventTypes": ["*"] }'
  • targetUrl must be HTTPS and must not point at a private, loopback, or link-local address.
  • eventTypes is either ["*"] (all events) or an explicit list of event types, e.g. ["message.received", "call.ended"].

The response includes a signing secret (whsec_…). The dashboard masks it and lets you copy it on demand; over the API the full secret is returned at creation and via GET /api/v1/webhooks/{id}/secret.

Send different numbers to different endpoints

By default a webhook receives events for every number on your account. To send some numbers’ events to a different endpoint, limit a webhook to specific numbers with phoneNumberIds, using the id of each number from GET /api/v1/numbers:

curl -X POST https://api.getdial.ai/v1/webhooks \
-H "Authorization: Bearer $DIAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"targetUrl": "https://example.com/dial/support",
"eventTypes": ["message.received", "call.ended"],
"phoneNumberIds": ["pn_123", "pn_456"]
}'

Change the numbers later with PATCH /api/v1/webhooks/{id}. You can send any of targetUrl, eventTypes, and phoneNumberIds; the signing secret stays the same:

curl -X PATCH https://api.getdial.ai/v1/webhooks/wh_123 \
-H "Authorization: Bearer $DIAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phoneNumberIds": ["pn_789"] }'

Three rules to know:

  • Omitted means all numbers. Leave phoneNumberIds out (or send null) and the webhook covers every number on the account, including numbers you add later. On update, null switches a limited webhook back to all numbers, and leaving the field out keeps it as it is.
  • An empty list is rejected. "phoneNumberIds": [] returns 400, as does an id that isn’t one of your active numbers. To cover every number, omit the field instead.
  • Released numbers never widen a webhook. If you release every number a webhook was limited to, it stays limited: it receives nothing, and phoneNumberIds reads back as [], until you edit it. It does not fall back to all numbers.

Delivery format

Each delivery is an HTTP POST to your targetUrl with the event JSON as the body and these headers:

HeaderValue
Content-Typeapplication/json
X-Dial-Event-TypeThe event type, e.g. message.received — use it to route/filter quickly.
X-Dial-Event-IDA stable per-event ID. Deduplicate on this — the same event may be delivered more than once.
X-Dial-Signaturet=<unix-seconds>,v1=<hex-hmac> — verify before trusting the body.

The body is the same event envelope you’d get from the event stream, plus a top-level id field equal to X-Dial-Event-ID.

Verify the signature

The signature protects against forged and replayed deliveries. Recompute the HMAC over {timestamp}.{raw_request_body} with your subscription secret and compare:

import crypto from "node:crypto";
// rawBody MUST be the exact bytes received, not a re-serialized object.
function verifyDialWebhook(secret: string, header: string, rawBody: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = parts.t;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
// constant-time compare
const ok = crypto.timingSafeEqual(Buffer.from(parts.v1, "hex"), Buffer.from(expected, "hex"));
// reject deliveries older than 5 minutes to limit replay
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return ok && fresh;
}

Retries

Dial considers a delivery successful on any HTTP 2xx. Anything else — a non-2xx status, a connection error, or a timeout (10s per attempt) — is retried with exponential backoff, up to 6 attempts total (1 initial + 5 retries). Because deliveries retry, your endpoint must be idempotent: dedupe on X-Dial-Event-ID.

Test with a ping

The dashboard’s Fire ping button (and POST /api/v1/webhooks/{id}/ping) sends a webhook.ping event to that subscription, bypassing its eventTypes filter. When the ping is delivered and your endpoint returns 2xx, the dashboard records the time as Last ping. Use it to confirm a new endpoint is reachable and verifying signatures correctly.