Open today · 9am - 7pm
DriveWide — More Cars. More Approval. More You.Run list
Technology and marketing

Dialpad API Tutorial: Pull Call Logs and Put Calls on CRM Leads

How DriveWide lists concluded Dialpad calls, collapses office and user legs, and matches the outside number to a lead.

Share this article

What the lead page needed#

A lead at DriveWide already had the website form, the texts, and the chat. The phone call that started the conversation was still only in Dialpad’s call history. Opening the lead should show that call next to the messages: incoming, outgoing, missed, or voicemail, who handled it, and how long they talked.

Dialpad’s call list was the right source. It is a read of calls that have already ended. We did not need to place calls through the API, and we did not need call recordings on the lead.

Use a company key, and keep it on the server#

A company admin creates the key in Dialpad under Admin Settings, My Company, Authentication, API Keys, and adds the calls:list scope. Dialpad shows the key once. We store it as DIALPAD_API_KEY on the server. The article and the code samples use that name only.

With no target filter, a company-level key lists the company’s calls. A user-level key lists that user’s calls, which would miss the rest of the store. Dialpad’s authentication guide allows the key as a query parameter and recommends against it, because the key then shows up in logs. We send it only as Authorization: Bearer.

List concluded calls, then follow the cursor#

Call — List returns calls that have already concluded, in reverse chronological order by start time. A call that is still ringing is absent until it ends. started_after and started_before are UTC milliseconds. Each response is { cursor, items }. Pass cursor back until it is empty.

Dialpad documents 20 requests per second per company (rate limits), and the call list reference lists 1,200 per minute. One request every 15 minutes is far below both. We leave include_anonymized at its default of false, so calls tied to deleted users stay out of the import.

Node.js · list concluded calls
const key = process.env.DIALPAD_API_KEY;

async function listCalls(startedAfter) {
  const calls = [];
  let cursor = "";
  do {
    const url = new URL("https://dialpad.com/api/v2/call");
    url.searchParams.set("started_after", String(startedAfter)); // UTC milliseconds
    if (cursor) url.searchParams.set("cursor", cursor);
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${key}`,
        Accept: "application/json",
      },
    });
    if (!response.ok) throw new Error(`Dialpad call list failed: ${response.status}`);
    const payload = await response.json(); // { cursor, items }
    calls.push(...(payload.items || []));
    cursor = typeof payload.cursor === "string" ? payload.cursor : "";
  } while (cursor);
  return calls;
}
curl · list concluded calls
curl -s "https://dialpad.com/api/v2/call?started_after=1761696000000" \
  -H "Authorization: Bearer $DIALPAD_API_KEY" \
  -H "Accept: application/json"
Python · list concluded calls
import os, requests

def list_calls(started_after_ms):
    calls, cursor = [], ""
    while True:
        params = {"started_after": str(started_after_ms)}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(
            "https://dialpad.com/api/v2/call",
            params=params,
            headers={"Authorization": f"Bearer {os.environ['DIALPAD_API_KEY']}",
                     "Accept": "application/json"},
            timeout=30,
        )
        r.raise_for_status()
        payload = r.json()  # { cursor, items }
        calls.extend(payload.get("items") or [])
        cursor = payload.get("cursor") if isinstance(payload.get("cursor"), str) else ""
        if not cursor:
            return calls

Polling the call list vs. Dialpad webhooks#

Dialpad can also push call events to a webhook. You create a webhook, then a call event subscription for the company, an office, or a narrower target such as a user or call center (Dialpad Call Events). Events arrive as each call changes state (for example ringing, connected, hangup), may arrive out of order, and are signed as a JWT with a secret you share with Dialpad (Dialpad Event Subscriptions). Subscriptions are created with POST https://dialpad.com/api/v2/subscriptions/call (Dialpad reference).

We chose polling for the lead timeline because the call list returns finished calls only, re-reading three days catches leads created after the call, and the same code runs the history backfill. Webhooks fit better when you need something to happen the moment a call ends, such as a missed-call alert to a salesperson.

Scroll to see full table

Call list (polling)Call event webhooks
TimingEvery run, after the call endsEach state change, close to real time
SetupAPI key, one scheduled jobPublic HTTPS endpoint, webhook, subscription, JWT verification
History backfillYesNo, only new events
Best forTimelines, reporting, CRM historyMissed-call alerts, live dashboards

Turn several legs into one conversation#

One inbound call can come back as several rows. On a real DriveWide call, Dialpad returned the office line, the user who answered, and another user who did not pick up. master_call_id was empty on all three. The office row’s call_id was the other rows’ entry_point_call_id. Grouping on master_call_id alone would have saved three calls.

The conversation id is entry_point_call_id, then master_call_id, then call_id. When a group has a user leg whose duration is at least 1,000 milliseconds, we keep that leg. If two users both connected, the longer duration wins. The saved start time is the earliest start in the group. Voicemail, recorded, and transferred flags are true if any leg set them.

Dialpad documents duration as the call length in milliseconds, and total_duration as that length including ring time. We store duration. Ring time is not talk time on the timeline.

Node.js · collapse legs
function conversationId(call) {
  return String(call.entry_point_call_id || call.master_call_id || call.call_id);
}

function connectedUser(call) {
  return call.target?.type === "user" && Number(call.duration) >= 1000;
}

function hasVoicemail(call) {
  return Boolean(call.voicemail || call.voicemail_link || call.voicemail_recording_id);
}

function normalize(call) {
  return {
    ...call,
    date_started: Number(call.date_started),
    duration: Number(call.duration) || 0,
    was_recorded: Boolean(call.was_recorded),
    is_transferred: Boolean(call.is_transferred),
    voicemail: hasVoicemail(call),
  };
}

// One customer conversation can arrive as an office leg plus one or more user legs.
function collapse(calls) {
  const grouped = new Map();
  for (const raw of calls) {
    const call = normalize(raw);
    const id = conversationId(call);
    const current = grouped.get(id);
    if (!current) {
      grouped.set(id, call);
      continue;
    }
    const chosen =
      [current, call].filter(connectedUser).sort((a, b) => b.duration - a.duration)[0] ||
      (call.duration > current.duration ? call : current);
    grouped.set(id, {
      ...chosen,
      date_started: Math.min(current.date_started, call.date_started),
      was_recorded: current.was_recorded || call.was_recorded,
      is_transferred: current.is_transferred || call.is_transferred,
      voicemail: current.voicemail || call.voicemail,
    });
  }
  return [...grouped.values()];
}

Match the outside number to the lead#

The customer number is external_number, or contact.phone when the first field is missing. We strip everything that is not a digit. An 11-digit value that starts with 1 loses that leading 1. What remains has to be a 10-digit US number, first digit 2–9. anonymous and other blocked values fail that check and are not stored.

Lead phone numbers go through the same function, so (386) 555-0142, +1 386-555-0142, and 3865550142 are one key. A number shared by two leads is attached to both. Each import deletes that conversation’s lead links and writes them again, so a corrected lead phone is picked up the next time that call is read.

Node.js · national number
function nationalDigits(phone) {
  const digits = String(phone || "").replace(/\D/g, "");
  const national = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits;
  return /^[2-9]\d{9}$/.test(national) ? national : "";
}

// "(386) 555-0142", "+1 386-555-0142", and "3865550142" all become "3865550142".
// "anonymous" and other blocked values become "".

What we store, and what we leave in Dialpad#

The lead page does not call Dialpad. It reads two tables: one row per conversation, and a link from that conversation to each matching lead. Opening a lead never needs the API key.

Scroll to see full table

Dialpad fieldWhat we keep
entry_point_call_id, master_call_id, call_idOne conversation id, and the call id of the leg we kept
date_started, then rang, connected, or endedThe earliest start in the conversation, as a timestamp
directioninbound or outbound
durationMilliseconds. We do not store total_duration
stateThe state string, capped at 40 characters. The timeline does not show this raw value
external_number or contact.phoneThe 10-digit national number, used only for matching
target.name and target.typeA short name, and whether the target was a user. Names that contain an email, a URL, or seven or more digits are dropped
Voicemail, was_recorded, is_transferredThree booleans. The URL and the recording id are not saved

Recording share links, screen recordings, voicemail URLs, transcription text, the internal number, contact email, and the target’s phone number are left in the API response. The timeline text is checked so a phone number cannot slip into the name or the label.

How one call reads on the lead#

Talk time of at least one second counts as a connected call. Anything shorter is a miss, unless a voicemail was left. Two calls with the same label both stay on the timeline. Text messages with the same body are collapsed. Calls are not.

Scroll to see full table

CallLabelDetail
VoicemailVoicemailLeft for the staff name, when we have one
Inbound, at least 1 secondIncoming callAnswered by the staff name, plus the talk time
Inbound, under 1 secondMissed callNo answer
Outbound, at least 1 secondOutgoing callThe staff name called, plus the talk time
Outbound, under 1 secondOutgoing callNo answer
Dealership lead timeline showing Dialpad incoming, outgoing, missed and voicemail calls with staff name and talk time
A lead timeline with test data: incoming, outgoing, missed, and voicemail. No phone numbers are shown.

The lead query returns at most 100 calls, oldest first. The phone number is not one of the columns that query reads.

Refresh every 15 minutes, and backfill once#

A scheduled job calls GET /api/cron/dialpad-calls with Authorization: Bearer and a separate cron secret. The regular run reads the last three days and stops after 15 pages. Because those three days are read again each time, a lead created today is linked to calls from that window on the next run. Calls older than three days stay as they were linked.

The first history load is a separate backfill. It clears the two call tables, then walks backward from the later of two dates: one day before the oldest lead that has a phone number, or 400 days ago. It takes up to 25 passes of 40 pages, setting started_before to just before the oldest call in the previous pass. That flag is destructive. The 15-minute job is not.

That first backfill reported 30 pages and 728 call records. After collapsing legs and dropping numbers that were not a 10-digit US number, it stored 414 conversations and wrote 142 links from those conversations to leads. Its start bound was 2025-09-24. Calls before that bound were not imported.

Scroll to see full table

First backfillResult
Pages read30
Call records returned728
Conversations after collapsing legs414
Links written to leads142
Start bound2025-09-24

Mistakes that double-count a call#

Scroll to see full table

What you seeWhyWhat we do
Three timeline rows for one ringmaster_call_id is empty, so each leg looks newGroup on entry_point_call_id first
Talk time includes the ringtotal_duration includes ring timeStore duration
The same number misses the leadThe lead is stored as 10 digits and the call as 11Compare the 10-digit national number
A recording URL lands on the leadThe list response includes share links and transcription textKeep a boolean, and drop the URL
Two missed calls become oneThe text-message deduper treats identical bodies as the same messageSkip that deduper for calls

Why calls belong on the lead#

Phone calls are often the first contact on a used-car lead, and they used to live only in Dialpad. With calls on the lead, a salesperson opening a credit application or web lead sees whether the customer already called, who answered, and whether the call was missed. Missed calls become a visible follow-up instead of a forgotten notification.

FAQ#

Does the Dialpad call list include calls that are still ringing?

No. Call — List returns only calls that have already concluded, newest first.

Can a user API key list every call in the company?

No. With no target filter, a company-level key lists the company’s calls. A user-level key lists that user’s own calls.

Why does one phone call come back as several API rows?

Dialpad records a separate leg for the office or department and for each user the call rang. We group those rows by entry_point_call_id, then master_call_id, then call_id, and keep the connected user leg.

Do you store recordings or show the customer’s phone number on the timeline?

No. We store whether a voicemail or recording exists. The timeline shows the call type, who handled it, and how long they talked. Phone numbers, recording URLs, and transcripts stay off the timeline.

How soon does a new call show up on the lead?

A scheduled job runs every 15 minutes and re-reads the last three days. A call appears after it has concluded and the next run imports it.

What if two leads share a phone number?

The call is attached to every lead with that number. A number that is anonymous, blocked, or not a 10-digit US number is not stored.

Does Dialpad have webhooks for calls?

Yes. Create a webhook, then a call event subscription. Events are sent as calls change state and are JWT-signed with a shared secret. See polling versus webhooks.

What scope does the Dialpad call list need?

calls:list, on a company-level API key if you want every call in the company.

Is Dialpad call duration in seconds or milliseconds?

Milliseconds. duration is the call length, and total_duration includes ring time.

Sources#

  1. Dialpad Call — List
  2. Dialpad rate limits
  3. Dialpad authentication basics
  4. Dialpad API key generation
  5. Dialpad Call Events
  6. Dialpad Event Subscriptions
  7. Dialpad call event subscription

The wholesale list

See the run list →