Webhooks push events out of dialnote the moment they happen. When a call ends, a recording finishes processing, an AI summary is ready, someone texts one of your numbers, or a teammate writes a note, dialnote POSTs a JSON payload to a URL you control. No polling, no scheduled sync.
This guide takes you from nothing to a working webhook, then covers what to build on top of it. If you just want the field list, jump to the Event Reference.
What You'll Need#
Two things before you start:
- A destination URL that accepts a POST over HTTPS and is reachable from the public internet. The next section covers your options.
- Access to Settings. Webhooks live under Settings → Webhook Management in dialnote.
- A Business or Pro plan. Webhooks aren't included on Solo or Team.
Webhooks push, the API pulls
Webhooks are for reacting to things as they happen. If you instead need to fetch data on your own schedule, use API Access, or point an AI assistant at your data over MCP.
Choose Your Destination#
Decide this before you create anything, because the provider you pick changes the payload your endpoint receives.
| Destination | Provider to select | Pick this when |
|---|---|---|
| Zapier | Zapier | You want prebuilt actions for 5,000+ apps and don't want to write code |
| Make or n8n | Custom Webhook | You want a visual builder with branching, and full access to dialnote's payload |
| Your own server | Custom Webhook | You need the raw payload, signature verification, or custom business logic |
In Zapier, Make, or n8n, create a scenario with a webhook trigger and copy the URL it generates. For your own server, deploy an endpoint that accepts a POST and returns 200.
Zapier receives a different payload shape
Zapier-provider webhooks get a reshaped body built for the Zapier app, and it drops conversationId, callId, and phoneNumberId. If you need those identifiers, choose Custom Webhook instead. Details in the Zapier payload shape table.
Create Your First Webhook#
With your destination URL in hand, go to Settings → Webhook Management and click Add Webhook. Budget about five minutes.
- Name it something you'll recognize later, like "CRM Call Sync" or "Lead Alerts". Up to 100 characters.
- Pick the provider you settled on above: Zapier for a
hooks.zapier.comURL, or Custom Webhook for Make, n8n, and your own servers. - Paste your webhook URL. It has to start with
https://. dialnote won't send events to an HTTP endpoint. It also doesn't follow redirects, so paste the final URL (a redirect counts as a failed delivery). - Tick the events you want under Events to send. Start with Call logs, which fires on every completed call. The full list is just below.
- Save.
- Click Test on the new webhook card. A sample payload goes out immediately, so you can confirm the endpoint is reachable without waiting for a real call.
- Place a real call to your dialnote number and hang up. Within seconds the
call.completedevent lands at your URL.
If step 6 worked but step 7 didn't, check that Call logs is actually ticked. See Troubleshooting.
Event Types#
Events to send is a checkbox list grouped into three categories. Tick any combination you want on a single webhook:
| Category | Checkbox | Event type | What fires |
|---|---|---|---|
| Calls | Call logs | call.completed | A call ends, whether answered, missed, or failed |
| Calls | Call recordings | call.recording.completed | A recording is ready. Needs recording enabled on the number. |
| Calls | Call summaries | call.summary.completed | An AI summary is ready, or the call couldn't be summarised. Needs AI summaries enabled on the number. |
| Messages | Inbound SMS | sms.received | Someone texts one of your numbers |
| Messages | Inbound MMS | mms.received | An inbound message carries photos or other media |
| Notes | Notes | note.created | A teammate adds a note to a conversation (not to a contact) |
That's the complete list. One webhook can carry all six if you want, and a webhook with nothing ticked stays saved but receives no events. Field lists for each are in the Event Reference.
Pausing without unsubscribing
Ticking nothing isn't the only way to stop delivery. Every webhook also has an Active / Inactive state shown on its card, with an Enable button when it's off. Use that to pause an integration, and leave your event selection intact.
A few rules that catch people out on the two message events:
sms.receivedandmms.receivedare mutually exclusive for any given message. Classification is by whether media is attached, not by the provider's media count. An international MMS that arrives as a short media link in the message body gets unwrapped into real attachments and delivered asmms.received.- Inbound only. Messages your team sends don't fire either event.
- SMS only. Inbound WhatsApp produces neither event.
- Both default to off, including on webhooks you created before they existed, so nothing you already built started receiving new traffic.
What doesn't reach a webhook
Outbound messages you send, AI agent call activity, call queue changes, and call tags all exist inside dialnote, but none of them reach a webhook. To act on those, poll the API instead.
Events fire after the fact, not while a call is ringing
call.completed fires when a call ends. There is no pre-answer or ringing webhook, so webhooks can't drive a screen-pop while the phone is still ringing.
If You Chose Zapier#
Zapier URLs work as soon as you save the webhook. One extra step wires it to your Zap:
- Go to Settings → API Keys (admins only) and create a key. Leave Read-only key off, because Zapier triggers need a full-access key. It starts with
dn_live_. - Paste that key when you connect the dialnote app in Zapier. The app sends it as an
Authorization: Bearerheader.
Your Zap also receives an X-DialNote-Provider: zapier header, so it can tell dialnote traffic apart from other sources.
Zapier API key
The API key gives access to your organization's dialnote data. Keep it secure and don't share it publicly. For the full Zap setup walkthrough, see the Zapier integration guide.
Zapier handles parsing and delivery acknowledgement for you, so skip the next section and continue at Delivery and Reliability.
Building Your Endpoint#
This section is for Custom Webhook destinations, including Make and n8n when you want to verify signatures yourself.
dialnote POSTs JSON to your URL. Every event has its own sample payload and field table in the Event Reference: call.completed, call.recording.completed, call.summary.completed, sms.received, mms.received, and note.created. Since one webhook can carry several events, branch on the envelope's type before you touch data.
Identifying your dialnote number
data.phoneNumber is your dialnote line on every call event: id is the line's identifier and number is the line in E.164. data.phoneNumberId holds the same identifier. For the other party, read fromNumber on inbound calls and toNumber on outbound ones.
Verifying the signature#
Each webhook gets a unique secret, starting with whsec_ and shown beside the webhook in Settings → Webhook Management. dialnote signs every request with HMAC-SHA256 over the exact bytes it sends, and puts the result in the X-DialNote-Signature header as sha256=<hex>. Each request also carries X-DialNote-Signature-V2: t=<unix seconds>,v1=<hex>, where v1 is the same HMAC over {t}.{raw body}, plus X-DialNote-Event-Id and X-DialNote-Delivery-Id headers.
Always verify that signature before you process a payload. Without the check, anyone who learns your URL can post fake calls into your systems.
Replay protection
The sha256= signature has no timestamp, so on its own it stays valid indefinitely. To reject replayed requests, verify X-DialNote-Signature-V2 and drop anything whose t is more than 300 seconds old. Use the event id (or X-DialNote-Delivery-Id, which stays the same across retries) as an idempotency key.
A complete receiver#
Putting it together: verify the signature, acknowledge fast, then process. Here's the whole thing in Express.
const crypto = require('crypto');
const express = require('express');
const app = express();
// Event ids you've already handled. In production this belongs in Redis or a
// table, not memory — a restart would forget everything and reprocess.
const seen = new Set();
function verifySignature(rawBody, header, secret) {
if (!header?.startsWith('sha256=')) return false;
const received = Buffer.from(header.slice('sha256='.length), 'hex');
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
// timingSafeEqual throws when lengths differ — check first.
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// express.raw() preserves the exact bytes we signed. express.json() would parse
// them away, and re-serializing can produce a different string.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-DialNote-Signature');
if (!verifySignature(req.body, header, process.env.DIALNOTE_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge BEFORE doing any real work. dialnote gives you 30 seconds, then
// counts the attempt as failed and sends the event again later.
res.sendStatus(200);
// Every event carries a unique `id`, so a repeat is safe to drop.
if (!seen.has(event.id)) {
seen.add(event.id);
setImmediate(() => handleEvent(event));
}
});
Three rules that account for most broken integrations:
- Hash the raw request body, not a re-serialized object. Parsing and re-stringifying JSON can reorder keys and change whitespace, which changes the hash.
- Strip the
sha256=prefix before comparing. - Answer
200before you process, then do the real work asynchronously. The next section explains why this one matters so much.
Delivery and Reliability#
Read this section before you go live. dialnote's delivery model is deliberately simple, and it puts real constraints on your endpoint.
Retries and failed deliveries#
Failed deliveries are retried for about four days
If your endpoint is unreachable, returns a non-2xx status, or takes longer than 30 seconds, dialnote tries again. Each delivery gets 8 attempts: one right away, then retries after about 15 minutes, 45 minutes, 2 hours, 5 hours, 12 hours, 24 hours, and 48 hours. A delivery that fails every attempt moves to Failed deliveries in Settings → Webhook Management, where you can Replay it (or Replay all) for 30 days. Retries resend the same event, so the receiver above answers 200 fast and drops repeats by id.
A second consequence worth knowing: if a call's status is corrected after the fact (recorded as not_picked, then later promoted to answered), the event is not re-sent, so a status you already received can become stale.
Auto-disable#
Three consecutive failed deliveries disable a webhook, so a dead endpoint doesn't get hammered forever. A delivery only counts as failed once all 8 attempts are used up, about four days after the event. Any successful delivery resets the counter to zero. The usual causes are an endpoint that moved, a server returning non-2xx, or a handler that ran past the 30 second limit.
A 410 Gone response is treated differently: it disables the webhook immediately, on the first response. Zapier returns 410 when a Zap has been deleted, and that's a deliberate unsubscribe rather than a fault.
Failure alerts#
You don't have to watch the settings page for any of this.
- Within minutes of deliveries starting to fail and retry, you get an in-app Webhook not responding notice (in-app only, no email).
- At the second consecutive failed delivery, one short of the cutoff, you get a warning while it's still fixable.
- When a webhook is auto-disabled, you get a second notification.
Alerts go out by email and as an in-app alert, to the organization's owners and admins plus whoever created the webhook. The person who built the integration is often not an admin, so both get told. A 410 Gone shutdown is recorded in-app only, with no email, because nothing is actually broken.
What the webhook card shows#
Each webhook in Settings → Webhook Management carries its own delivery status: Last sent (the most recent success), Failure count (consecutive failed deliveries, reset by any success), and Last error (the most recent error message). While retries are pending, the badge reads Retrying instead of Active. A disabled webhook also shows an Enable button. Fix the underlying problem before you click it.
What You Can Build#
With delivery working, here are patterns worth copying. Each is a single webhook plus a bit of logic on your side.
- Text back every missed call. Subscribe to
call.completed, branch whendata.statusisnot_pickedorfailed, and fire an SMS. This is the highest-value automation most teams build first, because it turns a lost call into a live conversation. - Log every call to a warehouse or spreadsheet. Write
data.callId,direction,status,duration, andfromNumberon eachcall.completed. You get a queryable call history that outlives any dashboard. - Route notes into your ticketing system. Subscribe to
note.created, and usedata.conversationIdto attach the note to the right customer record. - Pull recordings for QA review. Subscribe to
call.recording.completedand download the MP3 fromdata.recordingUrl. That link expires after 24 hours, so save the file, or fetch it later bydata.callIdfrom the API recordings endpoints. - Push call summaries into your CRM. Subscribe to
call.summary.completedand writedata.summary.text,keyPointsandactionItemsonto the matching record, keyed bydata.summary.callId. Your team reads what happened without opening the recording. - Alert on long or expensive calls. Branch on
data.durationto ping a channel when a call runs past a threshold. - Route inbound texts into your helpdesk. Subscribe to
sms.receivedand open a ticket fromdata.message.body, usingdata.conversationIdto thread replies onto the same conversation. - Save photo attachments customers text in. Subscribe to
mms.receivedand download eachdata.message.attachments[].urlto your own storage. Those links are signed and expire, so re-host anything you need to keep.
Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
| Test works, real calls don't | The event you're waiting on isn't ticked | Open the webhook and check Events to send. Test fires regardless of what's selected. |
| Nothing arrives at all | The webhook is Inactive, or nothing is ticked under Events to send | Check the card's Active/Inactive badge and click Enable, then confirm at least one event is ticked |
| Recordings never arrive | Call recordings isn't ticked, or recording isn't on for that number | Tick it, then check recording is enabled on the number itself |
| Inbound texts never arrive | Inbound SMS and Inbound MMS both default to off | Tick them. A text with media fires mms.received, not sms.received. |
| Signature check always fails | Hashing parsed JSON instead of the raw body, or not stripping sha256= | See Building Your Endpoint |
| Events stop after a few days | Three consecutive failed deliveries disabled the webhook | Check your email for the failure alert, fix and re-enable, then replay from Failed deliveries |
| Deliveries stopped right after a Zap change | A deleted Zap returns 410 Gone, which disables immediately | Recreate the Zap, then re-enable the webhook |
| Summaries never arrive | Call summaries isn't ticked, or AI summaries are off for that number | Tick it, then check AI summaries on the number. Calls too short to summarise still arrive, with status of not_applicable. |
| Missed calls never trigger | There's no missed-call event | Branch on call.completed with status not_picked or failed |
| A note you wrote didn't fire | It was a note on a contact, not on a conversation | Only conversation notes send note.created |
An MMS arrived as sms.received | It didn't actually carry media | Classification is by attachments present, not by the provider's media count |
The Test button sends a different shape
The test payload is a fixed sample with event_type set to test.webhook and a test: true flag. It doesn't match a real event, so use it to confirm connectivity, not to model your parsing logic. Build your parser against the samples in the Event Reference.
Managing Webhooks#
Each webhook card offers Edit (name, URL, or event), Test (send a sample payload), and Delete (permanent, no undo).
Changing the event on an existing webhook is the usual way to repoint an integration, and it takes effect on the next event. Deleting is the only way to change the secret: it can't be regenerated from the app, so delete the webhook and create a new one if you need to rotate it.
Event Reference#
Every field dialnote sends is listed below. "Always" means the field is in the payload on every delivery of that event (it may still be null where the type says so); "Optional" means it's omitted entirely when there's nothing to send, so read it defensively.
Event envelope#
Every custom-webhook event shares the same outer shape:
| Field | Type | Presence | Notes |
|---|---|---|---|
id | string (UUID) | Always | Unique per event. Use it as your idempotency key. |
type | string | Always | Which event this is, e.g. call.completed |
timestamp | string (ISO 8601) | Always | When dialnote emitted the event |
organizationId | string (UUID) | Always | Your dialnote organization |
data | object | Always | The event body, detailed below |
call.completed#
Fires when a call ends, whether it was answered, missed, or failed.
{
"id": "01924f8e-1234-7000-8000-000000000006",
"type": "call.completed",
"timestamp": "2026-08-04T10:03:00.000Z",
"organizationId": "01924f8e-1234-7000-8000-000000000001",
"data": {
"phoneNumberId": "01924f8e-1234-7000-8000-000000000002",
"conversationId": "01924f8e-1234-7000-8000-000000000003",
"callId": "01924f8e-1234-7000-8000-000000000004",
"callSid": "CA00000000000000000000000000000001",
"direction": "inbound",
"fromNumber": "+15551234567",
"toNumber": "+15559876543",
"phoneNumber": { "id": "01924f8e-1234-7000-8000-000000000002", "number": "+15551234567" },
"status": "answered",
"createdAt": "2026-08-04T10:00:00.000Z",
"duration": 180,
"hasRecording": true,
"endedBy": "contact",
"initiatedAt": "2026-08-04T10:00:00.000Z",
"answeredAt": "2026-08-04T10:00:05.000Z",
"endedAt": "2026-08-04T10:03:00.000Z",
"contactId": "01924f8e-1234-7000-8000-000000000005"
}
}
Every field in the data object:
| Field | Type | Presence | Notes |
|---|---|---|---|
phoneNumberId | string (UUID) | Always | Your dialnote line. The reliable way to tell which number took the call. |
conversationId | string (UUID) | Always | Groups calls, notes, and messages with the same contact |
callId | string (UUID) | Always | dialnote's call record. Use it against the API recordings endpoints. |
callSid | string | Always | Provider-side call ID |
direction | string | Always | inbound or outbound |
fromNumber | string (E.164) | Always | Calling party |
toNumber | string (E.164) | Always | Called party. On inbound calls, this is your dialnote line. |
phoneNumber | object | Always | { id, number }, always your dialnote line |
status | string | Always | answered, not_picked, failed, voicemail, or disconnected |
createdAt | string (ISO 8601) | Always | When the call record was created |
duration | integer (seconds) | Optional | 0 or more. Absent on calls that never connected. |
hasRecording | boolean | Optional | Whether a recording exists. Processing finishes in a separate event. |
endedBy | string | Optional | user, contact, specialist, ai, or system |
fromName | string | Optional | Display name for the calling party, when dialnote knows it |
toName | string | Optional | Display name for the called party, when dialnote knows it |
initiatedAt | string (ISO 8601) | Optional | When the call was placed |
answeredAt | string (ISO 8601) | Optional | Absent on a call nobody picked up |
endedAt | string (ISO 8601) | Optional | When the call terminated |
contactId | string (UUID) | Optional | Present when the call matched a contact |
failureCode | string | Optional | Failed calls only |
failureMessage | string | Optional | Failed calls only. A plain-language reason, safe to show to a person. |
Call status values#
data.status is one of answered, not_picked, failed, voicemail or disconnected.
There is no separate missed-call event. A missed call arrives as call.completed with a status of not_picked or failed:
const MISSED = new Set(['not_picked', 'failed']);
if (event.type === 'call.completed' && MISSED.has(event.data.status)) {
// handle the missed call
}
Raw provider statuses like no-answer, busy, completed and canceled are translated before the event is sent and never appear in data.status.
call.recording.completed#
Fires when a call recording has finished processing. This is a separate webhook from call.completed, so subscribe to it on its own if you want recordings.
{
"id": "01924f8e-1234-7000-8000-000000000007",
"type": "call.recording.completed",
"timestamp": "2026-08-04T10:03:45.000Z",
"organizationId": "01924f8e-1234-7000-8000-000000000001",
"data": {
"phoneNumberId": "01924f8e-1234-7000-8000-000000000002",
"conversationId": "01924f8e-1234-7000-8000-000000000003",
"callId": "01924f8e-1234-7000-8000-000000000004",
"callSid": "CA00000000000000000000000000000001",
"direction": "inbound",
"fromNumber": "+15551234567",
"toNumber": "+15559876543",
"phoneNumber": { "id": "01924f8e-1234-7000-8000-000000000002", "number": "+15551234567" },
"conversationUrl": "https://app.dialnote.com/conversations/01924f8e-1234-7000-8000-000000000003",
"recordingSid": "RE00000000000000000000000000000001",
"duration": 180,
"transcriptionSummary": "Caller asked about weekend availability and booked Saturday at 10am.",
"transcriptionStatus": "completed",
"isVoicemail": false,
"initiatedAt": "2026-08-04T10:00:00.000Z",
"endedAt": "2026-08-04T10:03:00.000Z",
"contactId": "01924f8e-1234-7000-8000-000000000005",
"tags": ["booking"]
}
}
Two fields you might expect are not here: this event carries no status (the call is over by definition) and no createdAt. Every field in the data object:
| Field | Type | Presence | Notes |
|---|---|---|---|
phoneNumberId | string (UUID) | Always | Your dialnote line |
conversationId | string (UUID) | Always | Groups the recording with the rest of the conversation |
callId | string (UUID) | Always | Pass this to the API recordings endpoints to fetch audio |
callSid | string | Always | Provider-side call ID |
direction | string | Always | inbound or outbound |
fromNumber | string (E.164) | Always | Calling party |
toNumber | string (E.164) | Always | Called party |
phoneNumber | object | Always | { id, number } |
conversationUrl | string | Always | Opens the conversation in dialnote. Not a downloadable audio file. |
recordingUrl | string | Optional | Direct link to the MP3 (audio/mpeg). Needs no credentials, works for 24 hours, and is fresh on each delivery attempt. Absent when a link can't be made. |
recordingUrlExpiresAt | string (ISO 8601) | Optional | When recordingUrl stops working |
recordingSid | string | Optional | Provider-side recording ID |
duration | integer (seconds) | Optional | Recording length |
fromName | string | Optional | Display name for the calling party |
toName | string | Optional | Display name for the called party |
transcriptionSummary | string | Optional | AI summary of the call |
transcriptionStatus | string | Optional | pending, completed, or failed |
initiatedAt | string (ISO 8601) or null | Optional | When the call was placed |
endedAt | string (ISO 8601) or null | Optional | When the call terminated |
isVoicemail | boolean | Optional | Whether the recording is a voicemail rather than a conversation |
contactId | string (UUID) | Optional | Present when the call matched a contact |
tags | array of string | Optional | Call tags applied by dialnote |
call.summary.completed#
Fires when an AI summary reaches a terminal state, whether or not a summary came out of it. Needs AI summaries enabled on the number. It's a separate delivery from the recording event and often arrives first, so don't wait for the recording before you handle it.
{
"id": "01a0ca5d-ff85-73fe-a3b0-5b4fe9476754",
"type": "call.summary.completed",
"timestamp": "2026-09-22T18:25:53.000Z",
"organizationId": "01924f8e-1234-7000-8000-000000000001",
"data": {
"phoneNumberId": "01924f8e-1234-7000-8000-000000000002",
"conversationId": "01924f8e-1234-7000-8000-000000000003",
"summary": {
"callId": "01924f8e-1234-7000-8000-000000000004",
"status": "completed",
"title": "Saturday booking moved to Monday",
"text": "The caller asked to move their Saturday appointment. Agreed on Monday at 10am and confirmed the address.",
"keyPoints": ["Saturday appointment no longer works", "Monday 10am agreed"],
"actionItems": ["Update the booking to Monday 10am", "Send a confirmation text"],
"sentiment": "positive"
}
}
}
Every field in the data object:
| Field | Type | Presence | Notes |
|---|---|---|---|
phoneNumberId | string (UUID) | Always | Your dialnote line |
conversationId | string (UUID) | Always | Ties the summary to the rest of the conversation |
summary | object | Always | The summary itself, fields below |
Inside summary:
| Field | Type | Presence | Notes |
|---|---|---|---|
callId | string (UUID) | Always | The call this summarises. Your join key back to call.completed, and what you pass to the API. |
status | string | Always | completed when there's a summary, not_applicable when there isn't |
failureReason | string | Optional | Why there's no summary. Present only when status is not_applicable. |
text | string | Optional | The summary itself |
title | string | Optional | A short headline for the call |
keyPoints | array of string | Optional | What was discussed |
actionItems | array of string | Optional | What someone agreed to do |
sentiment | string | Optional | positive, neutral, or negative |
A summary has no id of its own, since it belongs to a call. Match it with summary.callId.
Summary failure reasons#
not_applicable deliveries are deliberate. Silence looks the same as a broken integration, so dialnote tells you the call finished and why there's nothing to read:
failureReason | What happened |
|---|---|
transcription_too_short | The call was too brief to summarise |
empty_conversation | The transcript had no speech in it |
no_transcription | The call was never transcribed |
invalid_transcription_data | The transcript couldn't be read |
analysis_failed | Something went wrong while summarising |
Numbers with AI summaries switched off send no event at all, rather than a stream of not_applicable ones.
note.created#
Fires when a team member adds a note to a conversation.
Contact notes don't fire this event
dialnote has two kinds of notes. A note on a conversation sends note.created. A note on a contact record is a different event internally, and it is never delivered to a webhook. If you added a note and nothing arrived, check which one you wrote.
{
"id": "01924f8e-1234-7000-8000-000000000008",
"type": "note.created",
"timestamp": "2026-08-04T10:05:00.000Z",
"organizationId": "01924f8e-1234-7000-8000-000000000001",
"data": {
"phoneNumberId": "01924f8e-1234-7000-8000-000000000002",
"conversationId": "01924f8e-1234-7000-8000-000000000003",
"note": {
"id": "01924f8e-1234-7000-8000-000000000009",
"content": "Booked for Saturday 10am. Wants a quote on the second unit too.",
"authorId": "01924f8e-1234-7000-8000-00000000000a",
"author": {
"id": "01924f8e-1234-7000-8000-00000000000a",
"firstName": "Jordan",
"lastName": "Lee"
},
"createdAt": "2026-08-04T10:05:00.000Z"
},
"mentionedUserIds": []
}
}
There are no call fields and no contact fields on this event. Link a note back to a call through conversationId. Every field in the data object:
| Field | Type | Presence | Notes |
|---|---|---|---|
phoneNumberId | string (UUID) | Always | The line the conversation belongs to |
conversationId | string (UUID) | Always | Your join key back to the call |
note | object | Always | { id, content, authorId, author: { id, firstName, lastName }, createdAt } |
mentionedUserIds | array of UUID | Optional | Teammates @-mentioned in the note |
Note bodies can contain personal data
note.content is free text a teammate typed, so it may carry customer details. Treat the payload as sensitive and store it accordingly.
sms.received#
Fires when a text-only SMS arrives on one of your numbers.
{
"id": "019fef0c-5b0f-73cf-a28f-1ab61fbbf971",
"type": "sms.received",
"timestamp": "2026-08-11T04:19:52.207Z",
"organizationId": "019fef0c-5b0f-73cf-a28f-1f6af60dae5f",
"data": {
"phoneNumberId": "019fef0c-5b0f-73cf-a28f-2379b5cbc85b",
"conversationId": "019fef0c-5b0f-73cf-a28f-26f4590ac49f",
"fromNumber": "+15551234567",
"toNumber": "+15559876543",
"message": {
"id": "019fef0c-5b0f-73cf-a28f-2922d0daff6a",
"conversationId": "019fef0c-5b0f-73cf-a28f-2f2cd09a6546",
"channel": "SMS",
"direction": "INBOUND",
"contentType": "TEXT",
"body": "Can I move my Saturday appointment to Monday?",
"status": "DELIVERED",
"authorType": "EXTERNAL",
"createdAt": "2026-08-11T04:19:52.207Z"
},
"participant": {
"id": "019fef0c-5b0f-73cf-a28f-3a1d7c2ee9b4",
"channelAddress": "+15551234567"
}
}
}
Every field in the data object:
| Field | Type | Presence | Notes |
|---|---|---|---|
phoneNumberId | string (UUID) | Always | The dialnote line that received the message |
conversationId | string (UUID) | Always | Ties the message to the same conversation as calls and notes |
fromNumber | string | Always | Sender as the carrier reported it. Not always E.164: short codes (12345) and alphanumeric sender IDs (MyBank) arrive here too, so don't parse it as a phone number. |
toNumber | string (E.164) | Always | Your number that received it |
message | object | Always | The message itself, fields below |
participant | object | Always | The external sender, fields below |
Inside message:
| Field | Type | Presence | Notes |
|---|---|---|---|
id | string (UUID) | Always | Message record |
conversationId | string (UUID) | Always | Same conversation as data.conversationId |
channel | string | Always | Always SMS |
direction | string | Always | Always INBOUND |
contentType | string | Always | Always TEXT on this event |
body | string | Always | The message text. Required here, since a message with no body and no media isn't emitted. |
status | string | Always | Delivery status as recorded |
authorType | string | Always | Always EXTERNAL |
createdAt | string (ISO 8601) | Always | When the message was recorded |
Inside participant:
| Field | Type | Presence | Notes |
|---|---|---|---|
id | string (UUID) | Always | The external party on this channel |
channelAddress | string | Always | Their address, same caveat as fromNumber |
displayName | string | Optional | Name from the channel profile or a matched contact |
contactId | string (UUID) | Optional | Present when the sender matched a contact |
mms.received#
Fires when an inbound message carries at least one media attachment.
The envelope, participant, and most of message match sms.received. The differences: data.numMedia is added, message.attachments is added, message.body becomes optional (MMS is often media with no caption), and message.contentType reflects the media type rather than always being TEXT.
{
"id": "019fef0c-5b0f-73cf-a28f-41b0c7de5512",
"type": "mms.received",
"timestamp": "2026-08-11T04:22:10.412Z",
"organizationId": "019fef0c-5b0f-73cf-a28f-1f6af60dae5f",
"data": {
"phoneNumberId": "019fef0c-5b0f-73cf-a28f-2379b5cbc85b",
"conversationId": "019fef0c-5b0f-73cf-a28f-26f4590ac49f",
"fromNumber": "+15551234567",
"toNumber": "+15559876543",
"numMedia": 1,
"message": {
"id": "019fef0c-5b0f-73cf-a28f-4693ba01cd77",
"conversationId": "019fef0c-5b0f-73cf-a28f-2f2cd09a6546",
"channel": "SMS",
"direction": "INBOUND",
"contentType": "IMAGE",
"body": "Here's the photo of the unit",
"status": "DELIVERED",
"authorType": "EXTERNAL",
"createdAt": "2026-08-11T04:22:10.412Z",
"attachments": [
{
"type": "IMAGE",
"url": "https://storage.googleapis.com/bucket/inbound/org/a.jpg?X-Goog-Signature=abc",
"mimeType": "image/jpeg"
}
]
},
"participant": {
"id": "019fef0c-5b0f-73cf-a28f-3a1d7c2ee9b4",
"channelAddress": "+15551234567"
}
}
}
Fields beyond what sms.received carries:
| Field | Type | Presence | Notes |
|---|---|---|---|
numMedia | integer | Always | At least 1. dialnote's own count, which can differ from the provider's media count when links in the body were unwrapped into attachments. |
message.attachments | array | Always | At least one entry. Each is { type, url, mimeType }, where type is IMAGE, VIDEO, AUDIO, DOCUMENT, or LOCATION. |
message.body | string | Optional | Optional here, unlike on sms.received. Media often arrives with no caption. |
Attachment URLs expire after 7 days
url is a signed link with a 7-day lifetime, the maximum the storage provider allows. If you need the media beyond that, download it and re-host it on your side. Don't store the signed URL as a permanent reference.
Zapier payload shape#
This isn't a separate event you can subscribe to. It's the shape your endpoint receives whenever the webhook's provider is set to Zapier, and event_type tells you which of the two call events produced it. There's no note.created equivalent, and none of the dialnote UUIDs (conversationId, callId, phoneNumberId) survive the reshape. Pick Custom Webhook if you need them.
{
"id": "CA00000000000000000000000000000001",
"event_type": "call_log",
"contact_id": "01924f8e-1234-7000-8000-000000000005",
"timestamp": "2026-08-04T10:00:00.000Z",
"call": {
"sid": "CA00000000000000000000000000000001",
"direction": "inbound",
"phone_number": "+15551234567",
"from_number": "+15551234567",
"to_number": "+15559876543",
"from_name": null,
"to_name": "Main Line",
"status": "answered",
"duration": 180,
"initiated_at": "2026-08-04T10:00:00.000Z",
"answered_at": "2026-08-04T10:00:05.000Z",
"ended_at": "2026-08-04T10:03:00.000Z",
"recording_url": null,
"transcription_summary": null,
"transcription_status": null
}
}
| Field | Type | Presence | Notes |
|---|---|---|---|
id | string | Always | The provider-side call ID, same value as call.sid |
event_type | string | Always | call_log (from call.completed) or call_recording (from call.recording.completed) |
contact_id | string | Always | Contact the call was matched to. With no match, it's a placeholder contact_<digits of the other party's number>, not a UUID. |
timestamp | string (ISO 8601) | Always | When the call was placed, same value as call.initiated_at |
call | object | Always | Call details, fields below |
Inside call, every field is always present, though many can be null:
| Field | Type | Notes |
|---|---|---|
sid | string | Provider-side call ID |
direction | string | inbound or outbound |
phone_number | string | See the identifying-your-number warning above |
from_number | string | Calling party |
to_number | string | Called party |
from_name | string or null | Display name for the calling party |
to_name | string or null | Display name for the called party |
status | string | On call_log, the same values as data.status on call.completed. Always completed on call_recording. |
duration | integer (seconds) | 0 or more |
initiated_at | string (ISO 8601) | When the call was placed |
answered_at | string (ISO 8601) or null | null when nobody picked up, and always null on call_recording |
ended_at | string (ISO 8601) or null | When the call terminated |
recording_url | string or null | Populated on call_recording events |
transcription_summary | string or null | AI summary of the call |
transcription_status | string or null | pending, completed, or failed |
To try these against live data, or to generate types from the raw schemas, open the Outbound Webhooks section of the API reference. It's built from the same schemas the delivery code uses and covers call.completed, call.recording.completed, note.created, and the Zapier shape. sms.received and mms.received aren't in it.
What's Next#
- Zapier integration: connect calls and recordings to 5,000+ apps without writing code
- Make and n8n: build visual automations on top of dialnote webhooks
- CRM integrations: push call activity straight into HubSpot, Salesforce, or Pipedrive without a custom endpoint
- API Access: pull data on your own schedule, and fetch recording audio by
callId
The full list of tools dialnote connects to is on the integrations page.