Business Apps

Summarize Sales Calls to Your CRM with Python

This guide shows you how to turn a raw sales-call transcript into a tidy CRM note in under fifteen minutes. You will feed the transcript to an AI model, get back a structured summary with key points, next steps, and a sentiment read, then log that summary as an activity on the right contact — all from one Python script. No more re-listening to recordings or typing up notes by hand after every call.

This is one of the practical workflows in CRM Data Integration with AI. It reuses the same fetch-clean-enrich-write-back shape from the main guide, but the "enrich" stage here reads a conversation instead of a contact record, and the "write back" stage creates a note rather than updating a field.

Prerequisites

You need Python 3.10 or newer. Check with python --version. If Python is new to you, work through Setting Up Python for AI first, ideally inside a Python virtual environment so this project's packages stay isolated.

Install the packages this guide uses:

pip install openai httpx python-dotenv
  • openai is the official SDK for calling AI models.
  • httpx is a modern HTTP client we use to talk to the CRM's API (the doorway a program uses to talk to a service).
  • python-dotenv loads your secret keys from a file instead of hardcoding them.

Create a file named .env in your project folder for your credentials. A credential is just a secret your code uses to prove it is allowed to access a service.

OPENAI_API_KEY=sk-your-openai-key-here
CRM_API_TOKEN=your-crm-private-app-token
CRM_BASE_URL=https://api.hubapi.com

Add .env to your .gitignore so these secrets never get committed to version control:

echo ".env" >> .gitignore

If your AI keys are new to you, Understanding LLM APIs explains where they come from and how billing works. You also need the CRM contactId of the person the call was with — a number your CRM shows in the contact's URL or detail page. This guide assumes you already pull contacts from the CRM; the main CRM Data Integration with AI guide shows how, and so does Sync HubSpot Contacts with Python.

Step 1 — Load credentials and the transcript

Start by reading your secrets and creating two clients — small objects that hold connection details so you do not repeat them on every call. We build an OpenAI client for the summary and an httpx.Client for the CRM. For now we read the transcript from a plain text file named transcript.txt.

import os
import httpx
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # reads the .env file into environment variables

ai = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

crm = httpx.Client(
    base_url=os.environ["CRM_BASE_URL"],
    headers={"Authorization": f"Bearer {os.environ['CRM_API_TOKEN']}"},
    timeout=30.0,
)

with open("transcript.txt", "r", encoding="utf-8") as f:
    transcript = f.read()

Using os.environ["KEY"] (square brackets) rather than os.getenv("KEY") means the script stops with a clear error if a key is missing, instead of silently sending a blank token and failing later with a confusing message. This "fail fast" habit saves you from the most common beginner trap, where a typo in a key name produces a vague 401 error far down the script instead of an obvious one on the first line. The timeout=30.0 means "give up after 30 seconds" so the script never hangs forever on a slow response.

A word on the two clients, because they look similar but do different jobs. The OpenAI client knows how to format requests the AI model expects and read its replies — you never touch raw HTTP with it. The httpx.Client is more general: it can talk to any web API, and we point it at your CRM. Creating each client once and reusing it (rather than rebuilding it inside a loop) keeps the network connection alive, which matters when you summarize a batch of calls in one run.

About the transcript itself: this guide reads it from a file so the workflow is easy to test, but in practice it will arrive from wherever your calls live — a meeting tool's export, a webhook payload, or a row pulled straight from the CRM. Whatever the source, the only thing the rest of the script needs is a single string of text. If your transcript is very long — say an hour-plus call — trim it to the parts that matter before this step, both to control cost and to stay inside the model's input limit, since you pay for every word you send.

Step 2 — Summarize the transcript with an LLM

Now the core of the workflow. We ask the AI model to read the transcript and return a structured answer as JSON (a strict text format programs can parse). Forcing JSON with response_format means we never have to guess at free-form text — we get the same named keys every time.

import json


def summarize_call(transcript: str) -> dict:
    """Ask the AI model to summarize a sales call into structured fields."""
    system = (
        "You are a sales-operations assistant. Read the call transcript and "
        "reply only with JSON containing exactly these keys: "
        "'key_points' (a list of 3-5 short strings), "
        "'next_steps' (a list of short action strings, owner first if mentioned), "
        "and 'sentiment' (one word: 'positive', 'neutral', or 'negative')."
    )
    response = ai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": f"Transcript:\n{transcript}"},
        ],
        response_format={"type": "json_object"},
        temperature=0,  # 0 = consistent, repeatable summaries
    )
    return json.loads(response.choices[0].message.content)

We use gpt-4o-mini because summarizing a transcript does not need an expensive model, and temperature=0 makes the output stable so the same transcript always produces the same summary. Temperature controls how much randomness the model adds: at 0 it picks the most likely wording every time, which is what you want for notes that should read consistently. Keeping the system message (the role and the rules) separate from the user message (the transcript) is a small prompt-engineering habit that makes the model far more predictable — see Write System Prompts that Control Output Format for more on this. The result is a plain Python dictionary with three keys you can rely on.

Why force JSON at all? Because the alternative is parsing free-form sentences, which breaks the moment the model decides to be chatty or add a friendly preamble. By setting response_format={"type": "json_object"} and naming the exact keys you want, you get something Python reads with a single json.loads call, every time. The three keys here are deliberately chosen: key_points captures what was discussed, next_steps captures who owes what, and sentiment gives you a one-word read you can later filter or report on. If you wanted to extract more — budget mentioned, competitors named, objections raised — you would just add those keys to the system message and to the format step that follows. The structure scales as far as your sales process needs.

Step 3 — Format the summary as a CRM note

The JSON summary is great for code, but your sales team wants something readable. This step turns the dictionary into a short block of text with headings and bullet points that scans well inside the CRM.

def format_note(summary: dict) -> str:
    """Turn the structured summary into readable note text."""
    key_points = "\n".join(f"- {p}" for p in summary.get("key_points", []))
    next_steps = "\n".join(f"- {s}" for s in summary.get("next_steps", []))
    sentiment = summary.get("sentiment", "unknown").capitalize()

    return (
        "AI Call Summary\n\n"
        f"Sentiment: {sentiment}\n\n"
        f"Key points:\n{key_points}\n\n"
        f"Next steps:\n{next_steps}"
    )

This is plain text on purpose. Some CRMs render note bodies as HTML and some as plain text, so a clean, line-broken string is the safest default that looks right everywhere. If your CRM supports HTML notes, you can swap the - bullets for <ul><li> tags later. Using summary.get("key_points", []) instead of summary["key_points"] means the function still works even if the model omits a field, rather than crashing on a missing key.

Step 4 — Log the note on the CRM contact

Finally, we post the note to the CRM and link it to the contact. In HubSpot-style APIs a note is its own object that you create, then associate with a contact in the same request. We send a POST — the HTTP verb that means "create a new record" — with the note text and the contact association.

import time


def log_note(contact_id: str, note_text: str) -> str:
    """Create a note in the CRM and associate it with a contact."""
    payload = {
        "properties": {
            "hs_note_body": note_text,
            "hs_timestamp": int(time.time() * 1000),  # CRM expects epoch milliseconds
        },
        "associations": [
            {
                "to": {"id": contact_id},
                "types": [
                    {
                        "associationCategory": "HUBSPOT_DEFINED",
                        "associationTypeId": 202,  # note-to-contact link
                    }
                ],
            }
        ],
    }
    response = crm.post("/crm/v3/objects/notes", json=payload)
    response.raise_for_status()  # turn HTTP errors (401, 429, 400) into exceptions
    return response.json()["id"]

raise_for_status() is your safety net: if the CRM returns a 401 (bad token) or 400 (malformed payload), the script raises an exception instead of quietly continuing as if the note saved. The hs_timestamp field is required and must be epoch milliseconds — the number of milliseconds since 1970 — which is why we multiply time.time() by 1000. The associationTypeId of 202 is HubSpot's built-in code for linking a note to a contact; other CRMs use their own association mechanism, but the idea is identical: create the note, then point it at the contact so it shows up on that person's timeline.

Put the four functions together and the whole flow is four lines:

summary = summarize_call(transcript)
note_text = format_note(summary)
note_id = log_note(contact_id="12345", note_text=note_text)
print(f"Logged note {note_id} with sentiment: {summary['sentiment']}")

Quick reference

The settings you will most often adjust as you adapt this workflow.

ParameterTypeDefaultEffect
modelstr"gpt-4o-mini"Which AI model writes the summary. Bigger models cost more but handle long, messy calls better.
temperaturefloat0Randomness of the summary. 0 gives repeatable notes; raise toward 1 for more varied wording.
associationTypeIdint202The CRM code that links the note to a contact. Use your CRM's own value if you are not on HubSpot.

Troubleshooting

  1. json.decoder.JSONDecodeError — The AI reply was not valid JSON. Cause: response_format was omitted, so the model wrapped its answer in prose. Fix: keep response_format={"type": "json_object"} and tell the model to reply with JSON only. See Fix JSONDecodeError with AI API Responses in Python.
  2. 400 Bad Request when logging the note — The CRM rejected the payload. Cause: a missing required field like hs_timestamp, or a contact_id that does not exist. Fix: include hs_timestamp as epoch milliseconds and double-check the contact ID against the contact's URL in your CRM.
  3. httpx.HTTPStatusError: 401 Unauthorized — The CRM rejected your token. Cause: an expired or wrong token, or a missing Bearer prefix. Fix: regenerate the token in your CRM's private-app settings and confirm the header reads Authorization: Bearer <token>. The same logic for AI keys is in Fix the 401 Unauthorized Error in OpenAI Python.
  4. Summary cut off or an input-limit error on a long call — The transcript was too long for the model. Cause: sending an entire hour-plus transcript unfiltered. Fix: trim the transcript or summarize it in chunks first. See Fix the Context-Length-Exceeded Error in Python.

When to use this vs. alternatives

  • Use this script when you have call transcripts and want a consistent, structured note on every contact without manual typing — it is fast, cheap, and runs unattended over a batch of calls.
  • Use your meeting tool's built-in AI summary when you only need a human to read the recap and you do not care about a structured, machine-readable record in the CRM. Those summaries are convenient but live in the meeting tool, not on the contact, and you cannot control their format.
  • Use a fuller enrichment pipeline when you want to score the lead or update structured fields as well as log a note. In that case, pair this with Enrich CRM Leads with AI in Python, which writes AI results to custom fields rather than notes.

Back to CRM Data Integration with AI.