Webhooks push events out of dialnote the moment they happen. When a call ends, a recording finishes processing, 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.
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. - 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. |
| 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 five 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 Twilio'sNumMediacount. An international MMS that arrives as ap.twil.iolink 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:
- Click Show Key in the Zapier Integration section to get your Zapier API key
- Use that key in the
X-DIALNOTE-API-KEYheader when setting up webhook subscriptions in Zapier
Your Zap also receives an X-DialNote-Provider: zapier header, so it can tell dialnote traffic apart from other sources.
Zapier API key
The Zapier API key is unique to your organization. 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, 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
Use data.phoneNumberId, or data.toNumber on inbound calls. Don't use data.phoneNumber.number: on inbound calls it currently holds the caller's number, not your dialnote line, even though data.phoneNumber.id is your line. This is a known inconsistency.
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>.
Always verify that signature before you process a payload. Without the check, anyone who learns your URL can post fake calls into your systems.
No replay protection
The signature has no timestamp or nonce, so it stays valid indefinitely. Rely on TLS, and use the event id 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 and
// never retries, so a slow database write would cost you the event.
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.
One attempt, no retry#
A failed delivery is gone for good
dialnote attempts each delivery exactly once. If your endpoint is unreachable, returns a non-2xx status, or takes longer than 30 seconds, that event is lost. It is not queued or redelivered later. This is the reason the receiver above answers 200 before it does any work.
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 failures disable a webhook, so a dead endpoint doesn't get hammered forever. 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.
- At the second consecutive failure, one short of the cutoff, you get a warning while it's still fixable. With no retries, the next failed event would be the last one.
- 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 failures, reset by any success), and Last error (the most recent error message). 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 passdata.callIdto the API recordings endpoints to fetch audio. The event itself carries a link to the conversation, not the audio file. - 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 failures disabled the webhook | Check your email for the failure alert, then fix and re-enable |
| Deliveries stopped right after a Zap change | A deleted Zap returns 410 Gone, which disables immediately | Recreate the Zap, then re-enable the webhook |
| Wrong phone number in your CRM | Reading data.phoneNumber.number | Use data.phoneNumberId or data.toNumber |
| 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 Twilio's NumMedia |
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 |
source | string | Optional | twilio or system |
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",
"source": "twilio",
"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 (Twilio CA…) |
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 }. Read id; see the warning above about number. |
status | string | Always | answered, not_picked, failed, voicemail, or disconnected |
createdAt | string (ISO 8601) or null | Always | Can be null |
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) or null | Optional | When the call was placed |
answeredAt | string (ISO 8601) or null | Optional | On a call nobody picked up it's either absent or null, so check for both |
endedAt | string (ISO 8601) or null | Optional | When the call terminated |
contactId | string (UUID) | Optional | Present when the call matched a contact |
transcriptionSummary | string | Optional | AI summary, only if transcription finished before this event was sent |
failureCode | string | Optional | Failed calls only |
failureMessage | string | Optional | Failed calls only |
failureProvider | string | Optional | Failed calls only |
failureDocsUrl | string | Optional | Failed calls only |
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",
"source": "twilio",
"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. |
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 |
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",
"source": "twilio",
"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",
"messagingChannelId": "019fef0c-5b0f-73cf-a28f-3103431dfff7",
"messagingParticipantId": "019fef0c-5b0f-73cf-a28f-365b54ac444e",
"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 |
messagingChannelId | string (UUID) | Always | The channel that received it |
messagingParticipantId | string (UUID) | Always | Matches participant.id |
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 |
externalId | string | Optional | Provider-side message ID (Twilio SM…) |
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",
"source": "twilio",
"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",
"messagingChannelId": "019fef0c-5b0f-73cf-a28f-3103431dfff7",
"messagingParticipantId": "019fef0c-5b0f-73cf-a28f-365b54ac444e",
"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 Twilio's NumMedia 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": "01924f8e-1234-7000-8000-000000000006",
"event_type": "call_log",
"contact_id": "01924f8e-1234-7000-8000-000000000005",
"timestamp": "2026-08-04T10:03: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 | Event ID |
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 |
timestamp | string (ISO 8601) | Always | When the event was emitted |
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 | Same values as data.status on call.completed |
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 |
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, so it never drifts from the tables above.
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