Browse the docs
Connect your CRM
Dialing
Managing the team
Developers
Docs / Developers
Webhooks
Receive signed call.completed, lead.created and other events, and verify signatures in Node.js or Python.
Webhooks tell your systems when something happens in NitroDialer — a call finished, a lead arrived, someone asked not to be called — without polling the API.
Set up an endpoint
- In Settings → Webhooks, select Add endpoint.
- Enter an
https://URL that's reachable from the internet, and choose the events to receive. - Copy the signing secret (
whsec_…). It's shown once. - Select Send test to send a signed
pingevent and see what your endpoint answered.
Events
| Event | Sent when |
|---|---|
call.completed | An agent saved an outcome for a call. Includes the outcome, talk time, notes, and the Dialpad AI summary and recording link when available. |
lead.created | A new lead entered a campaign through the API or the speed-to-lead webhook. |
lead.dnc | A queued lead was stopped because its number was added to the internal do-not-call list. |
campaign.completed | Every lead in a campaign is done, exhausted or on the do-not-call list. |
Request format
NitroDialer sends a POST with a JSON body:
{
"id": "evt_1042",
"event": "call.completed",
"created_at": "2026-10-27T14:14:40.000Z",
"data": {
"id": "c41d9a6e-7b2f-4e8a-9f10-3d2c1b0a9f88",
"campaign": { "id": "8f0b5c2e-6c1a-4f43-9d7e-2b8c1f0a9e11", "name": "Heat pump rebate — web leads" },
"lead": { "id": "2b7e4f55-0d3c-4a11-8a2f-9c6d5e4b3a21", "first_name": "Daniel", "last_name": "Okafor", "company": null, "crm_object": "contacts", "crm_record_id": "51234" },
"agent": { "id": "0e6f7a8b-1c2d-4e3f-8a9b-0c1d2e3f4a5b", "name": "Priya Raman" },
"phone": "+15125550142",
"started_at": "2026-10-27T14:03:12.000Z",
"connected_at": "2026-10-27T14:03:30.000Z",
"ended_at": "2026-10-27T14:14:12.000Z",
"talk_seconds": 642,
"disposition": { "key": "appointment", "label": "Appointment set", "category": "contact_positive" },
"notes": "Gas furnace, 19 years old. Booked Thursday 10am.",
"ai_summary": "Homeowner wants to replace an aging furnace; in-home assessment booked.",
"recording_url": "https://dialpad.com/r/abc123"
}
}Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
NitroDialer-Event | The event name, e.g. call.completed |
NitroDialer-Signature | t=<unix seconds>,v1=<signature> |
User-Agent | NitroDialer-Webhooks/1.0 |
The id is unique per delivery. Store it and ignore repeats — retries reuse the same id.
Verify the signature
The signature is a hex HMAC-SHA256 of "<t>.<raw request body>" using your endpoint's signing secret. Always verify it against the raw body, before parsing JSON, and reject timestamps more than 5 minutes old to stop replays.
Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.NITRODIALER_WEBHOOK_SECRET; // whsec_…
app.post("/webhooks/nitrodialer", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("NitroDialer-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).send("stale");
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
const ok = given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(expected, "hex"));
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// …handle event.event / event.data, de-duplicating on event.id
res.sendStatus(200);
});Python
import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["NITRODIALER_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/webhooks/nitrodialer")
def nitrodialer():
header = request.headers.get("NitroDialer-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts.get("t", "0"))
if abs(time.time() - t) > 300:
abort(400)
body = request.get_data() # raw bytes
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
event = json.loads(body)
# …handle event["event"] / event["data"], de-duplicating on event["id"]
return "", 200Responding and retries
Answer with any 2xx status within 10 seconds. Do slow work after responding.
Anything else — a non-2xx status, a timeout, a connection error — is retried with exponential backoff, for 6 attempts in total. After the last attempt the delivery is marked failed. Each endpoint's recent deliveries, with the status and response we got, are listed in Settings → Webhooks.
Deliveries can arrive out of order and, rarely, more than once. Use created_at for ordering and id for de-duplication.
Turning an endpoint off
Switch an endpoint off in Settings → Webhooks to pause deliveries without losing its secret. Deleting it stops deliveries for good.