Can a voice bot integrate with HubSpot to log call outcomes and update lead status in real time?
Yes. A voice bot can log every call in HubSpot as a Call activity on the contact (outcome, summary, duration and recording) and update the contact's Lead Status automatically. With an end-of-call webhook this lands within seconds of hangup. If you need it while the caller is still on the line, for example to flag a hot lead before transfer, the agent can write to HubSpot mid-call through a custom tool.
What separates a working setup from a messy CRM is three decisions: how the call is matched to the contact, how bot outcomes map to HubSpot values, and when the write happens. This guide covers all three, with the payloads and code.
The short version
| What you want | How it works | When it lands in HubSpot |
|---|---|---|
| Log the call on the contact timeline | End-of-call webhook → POST /crm/v3/objects/calls associated to the contact |
Seconds after hangup |
| Update Lead Status | Same handler → PATCH /crm/v3/objects/contacts/{id} with hs_lead_status |
Seconds after hangup |
| Write extracted answers (budget, timeline, interest) | call.analyzed webhook → contact properties |
Once post-call analysis finishes |
| Update HubSpot during the call | Custom tool on the agent that calls HubSpot's API | Mid-conversation |
"Real time" for a CRM usually means before a rep looks at the record. The end-of-call webhook meets that bar for almost every team. Use the mid-call route only where an action depends on the update happening during the conversation.
Step 1: Match the call to the right HubSpot contact
The most common failure is writing the outcome to the wrong record, or to none. Avoid it by sending the HubSpot contact ID with the call, instead of searching by phone number afterwards.
When you place an outbound call through the Calls API, pass the ID as a variable:
curl -X POST https://voice-agent.edesy.in/api/v1/calls \
-H "Authorization: Bearer $EDESY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agentId": 1042,
"phoneNumber": "+14155550142",
"variables": {
"first_name": "Dana",
"hubspot_contact_id": "104901"
},
"callbackUrl": "https://your-app.com/hooks/voice/call-ended"
}'
variables is echoed back in the end-of-call payload, so your handler knows exactly which contact
to update. The same variables can be used in the agent's script, so the bot can greet the lead by name.
If the call is triggered by HubSpot itself (a new lead fills a form and gets called within a minute), see auto-calling new HubSpot leads with n8n. The contact ID is already in the trigger data there.
Inbound calls have no ID up front. Search HubSpot by the caller's number
(POST /crm/v3/objects/contacts/search with a phone filter), and create the contact if nothing
matches.
Step 2: Map bot outcomes to HubSpot values
HubSpot stores two different things, and a voice bot should set both:
- Call disposition (
hs_call_disposition): what happened on this call. It is set with an internal GUID, not a label. - Lead Status (
hs_lead_status): where the lead now stands in your sales process.
HubSpot's default call dispositions and their GUIDs, from HubSpot's calls API guide:
| Disposition | GUID |
|---|---|
| Connected | f240bbac-87c9-4f6e-bf70-924b57d47db7 |
| No answer | 73a0d17f-1163-4015-bdd5-ec830791da20 |
| Left voicemail | b2cf5968-551e-4856-9783-52b3da59a7d0 |
| Left live message | a4c4c377-d246-4b32-a13b-75a56a4cd0ff |
| Busy | 9d9162e7-6cf3-4944-bf63-4dff82258764 |
| Wrong number | 17b47fee-58de-441e-a44c-c6300d46f273 |
A practical mapping from voice-bot outcomes to both fields:
| What happened on the AI call | Call disposition | Lead Status (hs_lead_status) |
|---|---|---|
| Qualified, wants a demo or quote | Connected | OPEN_DEAL (or CONNECTED if reps create deals) |
| Interested, asked for a callback | Connected | CONNECTED, plus a task for the callback time |
| Interested, but not now | Connected | BAD_TIMING |
| Not a fit / not interested | Connected | UNQUALIFIED |
| No answer | No answer | ATTEMPTED_TO_CONTACT |
| Voicemail reached | Left voicemail | ATTEMPTED_TO_CONTACT |
| Wrong number | Wrong number | Leave as is and flag for review |
Lead Status options can be customised per portal, so check the internal values under Settings → Properties → Lead Status before relying on the defaults above.
To get the finer outcomes (interested vs not now vs not a fit), set up
post-call data extraction with a field like
lead_outcome that only accepts a fixed list of values. A fixed list maps cleanly to a table like this
one; free text does not.
Step 3: Receive the end-of-call webhook and write to HubSpot
Edesy gives you two ways to be told a call has ended:
Per-call callbackUrl |
Webhook subscription | |
|---|---|---|
| Set up | One field on POST /api/v1/calls |
Once per workspace, in the dashboard or API |
| Events | call.completed, call.failed, call.voicemail |
call.ended, call.analyzed and more |
| Signed | No | Yes, HMAC-SHA256 |
| Retried | No, single delivery | Yes |
| Best for | Getting started, low volume | Production |
The per-call callback payload looks like this (snake_case, disposition lowercased):
{
"event": "call.completed",
"timestamp": "2026-10-16T09:32:16Z",
"data": {
"conversation_id": "c3f1b6e2-8f5a-4a1e-9d21-7f0a2c4b9e11",
"phone_number": "+14155550142",
"duration": 74,
"disposition": "qualified",
"transcript_summary": "Dana runs a 12-person agency, wants a demo next Tuesday.",
"recording_url": "https://…",
"variables": { "first_name": "Dana", "hubspot_contact_id": "104901" }
}
}
A minimal handler that logs the call and updates Lead Status (Node.js, using a HubSpot private app token):
const HS = "https://api.hubapi.com";
const hs = (method, path, body) =>
fetch(HS + path, {
method,
headers: {
Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
"Content-Type": "application/json",
},
body: body && JSON.stringify(body),
}).then((r) => r.json());
const DISPOSITION = {
connected: "f240bbac-87c9-4f6e-bf70-924b57d47db7",
no_answer: "73a0d17f-1163-4015-bdd5-ec830791da20",
voicemail: "b2cf5968-551e-4856-9783-52b3da59a7d0",
};
// Only move leads forward from these; never overwrite a rep's decision.
const SAFE_TO_OVERWRITE = new Set(["", "NEW", "OPEN", "ATTEMPTED_TO_CONTACT"]);
app.post("/hooks/voice/call-ended", async (req, res) => {
res.sendStatus(200); // acknowledge fast, then do the work
const { event, timestamp, data } = req.body;
const contactId = data.variables?.hubspot_contact_id;
if (!contactId) return;
const reached = event === "call.completed";
const outcome = event === "call.voicemail" ? "voicemail" : reached ? "connected" : "no_answer";
// 1. Log the call on the contact timeline
await hs("POST", "/crm/v3/objects/calls", {
properties: {
hs_timestamp: timestamp,
hs_call_title: "AI voice agent call",
hs_call_body: data.transcript_summary ?? "",
hs_call_duration: String((data.duration ?? 0) * 1000), // HubSpot wants milliseconds
hs_call_direction: "OUTBOUND",
hs_call_status: reached ? "COMPLETED" : "NO_ANSWER",
hs_call_disposition: DISPOSITION[outcome],
hs_call_to_number: data.phone_number,
hs_call_recording_url: data.recording_url, // must be HTTPS
},
associations: [{
to: { id: contactId },
types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 194 }], // call → contact
}],
});
// 2. Update Lead Status, but only if no rep has moved it on
const contact = await hs("GET", `/crm/v3/objects/contacts/${contactId}?properties=hs_lead_status`);
const current = contact.properties?.hs_lead_status ?? "";
if (!SAFE_TO_OVERWRITE.has(current)) return;
const next =
data.disposition === "qualified" ? "OPEN_DEAL" : reached ? "CONNECTED" : "ATTEMPTED_TO_CONTACT";
await hs("PATCH", `/crm/v3/objects/contacts/${contactId}`, {
properties: { hs_lead_status: next },
});
});
The private app needs crm.objects.contacts.read and crm.objects.contacts.write, the scopes HubSpot
documents for its calls API. Create it under Settings → Integrations → Private Apps.
For production, switch to a webhook subscription. The per-call callback is delivered once with no
retry, so if your endpoint is down for a minute those outcomes are lost. Subscriptions are signed and
retried. Verify the signature, and use the event id as an idempotency key so a retry never logs the
same call twice. The webhook subscriptions docs have
verification code in several languages.
Writing the extracted answers too
call.ended tells you the call happened. call.analyzed fires after post-call analysis and carries the
summary and extracted fields, such as lead_outcome, budget, timeline, callback_time and
objection. Map each to a HubSpot contact property (create custom properties for the ones HubSpot lacks)
and write them in one PATCH. That is how the CRM ends up holding what the lead actually said, not just
"call completed".
No-code alternative
Point the webhook at an n8n Webhook node, a Make custom webhook or a Zapier catch hook, then add two HubSpot steps: create engagement (call) and update contact. The mapping table above still applies. Our Google Sheets guide shows the same pattern with a sheet as the destination.
Step 4 (optional): Update HubSpot during the call
Some actions can't wait for hangup:
- Hot lead, warm transfer. Set
hs_lead_statustoOPEN_DEALand assign an owner before the rep picks up, so the record is ready on their screen. - Personalised conversation. Look up the contact's lifecycle stage or last deal so the bot doesn't pitch a customer like a cold lead.
- Live booking. Write the agreed meeting time while the caller is still there to confirm it.
For these, give the agent a custom tool that calls
HubSpot's API. Custom tools support GET, POST, PUT, PATCH and DELETE with your own headers, so
a PATCH to /crm/v3/objects/contacts/{id} with a bearer token is a normal tool definition. Two
rules keep it smooth:
- Have the agent say a short line before the tool runs ("One moment, let me note that down") so the caller never hears silence.
- Keep mid-call writes few and fast. Do the status change mid-call if it matters, and leave the full call log and extracted fields to the end-of-call webhook.
See the tools API reference for the tool format.
Common mistakes
- Matching by phone number on outbound calls. HubSpot phone fields hold mixed formats
(
(415) 555-0142,+1 415…), and duplicates are common. Pass the contact ID with the call instead. - Using labels instead of GUIDs for
hs_call_disposition. HubSpot expects the internal ID. - Sending seconds for
hs_call_duration. HubSpot expects milliseconds, so a 74-second call shows as 0:00. - Overwriting rep decisions. An automated "no answer" must never push an
OPEN_DEALcontact back toATTEMPTED_TO_CONTACT. Read before you write. - Free-text outcomes. "Customer seemed kind of interested" can't drive a workflow. Use a fixed list of values.
- No retry handling. Use signed, retried webhook subscriptions and an idempotency key in production.
What it looks like for a sales team
A US agency gets inbound leads from Facebook forms into HubSpot. With this setup:
- A new contact enters HubSpot, and an AI call goes out within a minute.
- The bot qualifies on budget, timeline and decision-maker, and books a demo if the lead fits.
- Seconds after hangup, the contact timeline shows the call with summary and recording. Lead Status
is
OPEN_DEAL, and budget and timeline sit in contact properties. - A HubSpot workflow enrolled on
Lead Status = OPEN_DEALcreates a deal and notifies the owner. - Unanswered leads move to
ATTEMPTED_TO_CONTACTand are retried automatically.
Reps start from a record that already says what the lead wants, instead of calling cold. For the broader setup (custom properties, workflows and reporting dashboards), see the full AI voice agent + HubSpot guide. For everything else that should happen after a call, see the post-call CRM automation checklist.
Try it on your own HubSpot
Build an agent, call your own phone, and point the end-of-call webhook at the handler above (or at an n8n webhook). One test call shows the whole loop: call logged, status updated, fields filled.
Start building on Edesy, no demo needed →
Using a different CRM? The same pattern works for Pipedrive and any CRM with an HTTP API. See the HubSpot integration overview.