Business Apps

Summarize Sales Calls to Your CRM with Python

Turn a sales-call transcript into a structured summary with key points, next steps, and sentiment, then log it as a CRM note using the openai SDK and httpx.

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. If you only have the recording and no written text, Transcribe Audio with the Whisper API and Python produces exactly the string this script expects. 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. To put a number on that before you run a batch, Estimate OpenAI API Costs with Python walks through the arithmetic.

Before writing another line, it helps to see where each of those two clients is used. The transcript makes one trip out to the AI model and one trip into the CRM, and nothing else in the script talks to the network.

How one transcript travels from a text file to a CRM note A data-flow diagram in two rows: the transcript file feeds the Python script, which calls the OpenAI API; the returned summary JSON is formatted into note text and posted to the CRM contact. transcript.txt plain text file Python script loads .env keys OpenAI API gpt-4o-mini Summary JSON 3 named keys Note text human readable CRM note on the contact
Only two hops leave your machine: one to the AI model for the summary, one to the CRM to save it. Everything between them is ordinary Python string work.

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.

Here is what summarize_call hands back, key by key, with a realistic value for each so you can picture what your formatting code is about to read.

Anatomy of the JSON summary the model returns An annotated payload showing the three keys of the summary dictionary — key_points, next_steps and sentiment — each with a description of its contents and a sample value. Summary JSON "key_points" list of 3-5 strings ["Budget signed off"] what was discussed "next_steps" owner first if named ["Send pricing PDF"] who owes what next "sentiment" one word, one value "positive" filter or report on it
Naming the three keys in the system message is what makes this shape predictable — your formatting code can read each one without checking whether the model felt chatty today.

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, and Split Long Text into Chunks for AI APIs for a splitter that keeps sentences intact.

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. Once it works locally, Schedule Python AI Jobs with GitHub Actions shows how to run it every evening without leaving your laptop open.
  • 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.

Those three choices are easy to mix up when you are staring at a backlog of recordings, so walk the tree below from the top and take the first branch that matches your situation.

Deciding between a meeting-tool recap, this script, and a full enrichment run A decision tree starting from whether you have a written transcript, branching to the meeting tool's own recap if not, and otherwise to whether you also need structured CRM fields, which chooses between this script alone and pairing it with lead enrichment. Do you have a written transcript? no yes Use the meeting tool's own recap Need structured CRM fields too? no yes Run this script one note per call Add enrichment note plus fields
The transcript is the real gate: without one you are stuck with whatever your meeting tool writes, and with one you can choose how much structure to push back into the CRM.

Back to CRM Data Integration with AI.

Frequently asked questions

Do I need the call audio, or just the transcript?

Just the transcript text. This guide starts from a written transcript, which you can get from a meeting tool like Fireflies, Otter, or Zoom's built-in transcription, or from your own notes pasted into a text file.

Will the AI summary always return the same fields?

Yes, if you force JSON output and name the keys you want. By setting response_format to a JSON object and asking for key_points, next_steps, and sentiment by name, every call returns the same shape your code can read reliably.

Which CRMs can I log the note to?

Any CRM with a REST API, including HubSpot, Pipedrive, Salesforce, and Zoho. This guide uses HubSpot-style note and association endpoints, but the pattern of post a note then link it to a contact is the same everywhere.

Is it safe to send a call transcript to an AI model?

Send only the transcript text you need and strip out anything sensitive like payment details first. The OpenAI API does not train on data sent through the API by default, but you should still avoid sending more than the summary task requires.

How much does it cost to summarize a call?

Very little. A typical 30-minute transcript runs a few thousand tokens, so on a small model like gpt-4o-mini each summary costs a fraction of a cent. Cost scales with transcript length, so trim filler if your calls run long.