Developers

Webhooks & click-to-call.

Signed webhooks for every call event, and four ways to start a call from your CRM, from a plain link to the REST API.

Click-to-call

There are four ways to start a call from outside the dialpad, from zero setup to a full integration.

Inside the dialer, every phone number (in contacts, call lists, voicemail and callbacks) is a link. Click it and the softphone dials. In a tab that doesn't own the phone, the click is sent to the tab that does.

Any page can link straight into the agent workspace:

text
/agent?dial=+14155550148

The workspace opens with a confirmation strip, "Call +1 415 555 0148?", with Call and Dismiss. The agent confirms, and the call goes through the same checks as a dial from the dialpad: calling hours, DNC, caller ID and usage.

  • Auto-dial. Owners and admins can switch on deep-link auto-dial in Settings → General. Links then dial straight away, without the confirmation, whenever the agent's softphone is ready.
  • Another tab. If the phone lives in another tab, the call is placed there and the strip reads "Calling in your other tab".
  • Encoding. In a URL query, a raw + can be read as a space. Encode it as %2B when you build links in code: /agent?dial=%2B14155550148.
  • tel: links are accepted too, so /agent?dial=tel:+14155550148 works.

3. Make the dialer your tel: handler

Agents can make every tel: link in any web app open the dialer. In the agent workspace, open the audio menu and choose Open phone links with the dialer. The browser asks to confirm. From then on, clicking a tel: link in your CRM, helpdesk or email opens the deep link above, in browsers that support protocol handlers.

4. The REST API

For server-side integrations, POST /api/v1/calls places a call from a named agent's softphone. The agent's browser connects on its own, with no click. DNC and calling hours always block over the API, with no override.

Shell
curl -X POST "$DIALER_URL/api/v1/calls" \
  -H "Authorization: Bearer $DIALER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "agent_email": "agent@example.com", "to": "+14155550148" }'

See Place a call for every parameter and error.

Embedding in a CRM

The dialer can't be embedded in an iframe: it tells browsers never to render it inside another site's frame, and only the dialer's own pages may use the microphone. This protects agents from clickjacking and keeps the softphone in one place.

To connect a CRM, use a link or a new window instead:

Markup
<a href="https://dialer.example.com/agent?dial=%2B14155550148" target="dialer">Call</a>

A named target reuses the same tab for every click, so the agent keeps one workspace open next to the CRM. Or register the tel: handler and keep the CRM's own phone links. Replace the host with the address you use to sign in.

Webhooks

Webhooks push events to your server as they happen: calls ringing, answered and completed, recordings ready, new voicemail, callbacks scheduled, agents changing state. They're included on the Growth and Enterprise plans.

Add an endpoint

Owners and admins manage endpoints in Developers → Webhooks.

  • URL: must use HTTPS and resolve to a public address; private and internal addresses are rejected.
  • Events: pick the events you want, or all of them (*).
  • Secret: shown once when you create the endpoint, starting with whsec_. Rotate it at any time.
  • Send test delivers a sample event, and each endpoint has a deliveries log with status, attempts, response code, duration and the start of your response. Redeliver any delivery.

Payload

Every delivery is a POST with a JSON body:

Data
{
  "id": "0192f6d0-…",
  "type": "call.completed",
  "created_at": "2026-09-26T14:02:11Z",
  "tenant_id": "0192f6c0-…",
  "data": {
    "id": "0192f6c2-…",
    "direction": "outbound",
    "origin": "api",
    "status": "completed",
    "customer_number": "+14155550148",
    "agent": { "id": "0192f6c4-…", "name": "Example Agent", "email": "agent@example.com" },
    "talk_sec": 184,
    "metadata": { "crm_ticket": "T-1042" }
  }
}

The example is shortened. call.* events carry the full Call object.

Events

Fieldtypedescription
call.ringingRingingAn outbound customer is ringing, or an inbound call is offered to its first agent. Data: Call.
call.answeredConnectedAgent and customer are connected. Data: Call.
call.completedEndedThe call ended, whatever the outcome. Data: Call.
call.missedInbound, no agentAn inbound call ended without reaching an agent: abandoned, voicemail, after hours, overflow or no agents. Also fires call.completed. Data: Call.
call.abandonedInbound, hung upThe caller hung up in a queue after the short-abandon time. Also fires call.completed. Data: Call.
call.dispositionedWrap-upThe agent saved a disposition. Data: Call.
recording.completedRecording readyFires after call.completed, once the audio is ready. Data: id, call_id, duration_sec, url.
voicemail.createdNew voicemailData: id, call_id, from, duration_sec, queue_id, user_id, created_at.
callback.scheduledCallbackFrom wrap-up, a queue, the admin console or the API. Data: id, contact_id, phone, scheduled_at, assigned_user_id, notes.
contact.createdContactCreated by hand or through the API (not by CSV imports). Data: Contact.
agent.state_changedStatusAn agent changed status. Data: user_id, state, since, break_reason.
campaign.completedCampaignA campaign completed. Data: id, name, completed_at.

The url in recording.completed is the API path /api/v1/calls/{call_id}/recording: fetch it with an API key that has recordings:read.

Headers

Fieldtypedescription
webhook-idstringThe event id. Identical on every retry: use it to ignore duplicates.
webhook-timestampintegerUnix time in seconds when the delivery was signed.
webhook-signaturestringv1, followed by the base64 HMAC-SHA256 signature.
content-typestringapplication/json.
user-agentstringEnds in -Webhooks/1.

Deliveries follow the Standard Webhooks specification, so any Standard Webhooks library can verify them.

Verify the signature

The signature is an HMAC-SHA256 over the string webhook-id.webhook-timestamp.body, keyed with your secret without its whsec_ prefix, base64-decoded. Always verify against the raw request body, before parsing it.

JavaScript
import crypto from "node:crypto"
import express from "express"

const secret = process.env.DIALER_WEBHOOK_SECRET
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
const TOLERANCE_SEC = 5 * 60

function verify(headers, body) {
  const id = headers["webhook-id"]
  const timestamp = headers["webhook-timestamp"]
  const signatures = headers["webhook-signature"] ?? ""
  if (!id || !timestamp) return false

  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!(age <= TOLERANCE_SEC)) return false

  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${body}`)
    .digest("base64")

  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",")
    return (
      version === "v1" &&
      signature?.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    )
  })
}

const app = express()

app.post(
  "/webhooks/dialer",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const body = req.body.toString("utf8")
    if (!verify(req.headers, body)) return res.status(401).end()

    const event = JSON.parse(body)
    res.status(204).end()
    queueForProcessing(event)
  }
)

queueForProcessing stands for your own background work. Answer first, then process.

Retries and disabling

  • A delivery succeeds when your server answers with a 2xx status within 10 seconds. Redirects aren't followed.
  • Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, then marked dead. You can still redeliver them from the log.
  • After 3 days of consecutive failures, the endpoint is disabled automatically and the workspace is emailed. Fix your server, then re-enable the endpoint.
  • Retries mean an event can arrive more than once, and later events can overtake earlier ones. Deduplicate on webhook-id, and use created_at and the call's own timestamps to order what you store.