Developers · Tutorial

Build your call log

Every call Riff answers for your business, in a spreadsheet that you keep. You run one Python script. It makes a folder with calls.csv and a transcript of each call. When you run it again, only new and changed calls arrive.

Time: about 10 minutes. You need: a computer with Python 3.9 or newer, and an API key. Only an admin of your organization can make a key.

View as Markdown

What you will build

A folder named call-log. You should see three things in it after the first run:

Text
call-log/
  calls.csv       one row per call
  transcripts/    one file per call
  .state.json     where it stopped

This is calls.csv for the sample business, with 4 of its 14 columns:

caller_namecaller_phoneneedoutcome
Dana Ortiz+1 512-555-0147Kitchen sink — leaking under the cabinetbooked
Sam Patel+1 512-555-0183Water heater — leaking from the bottomemergency
no_booking
+1 512-555-0129Toilet — running all nightno_booking

The 14 columns, in order: id, started_at, duration_seconds, kind, line, caller_name, caller_phone, need, outcome, booking_start, booking_end, address, urgency, transcript_file. An empty cell means that Riff did not get that detail on the call. Do not edit .state.json: the script reads it to know where it stopped.

Step 1: Make a key

  1. Sign in on whappai.com and open the gear in the header.
  2. Open the API tab and choose Create key.
  3. Name the key "Call log" and choose Create.
  4. Choose Copy. Keep the key somewhere safe, like a password manager.

You should see a key that starts with rk_live_, and the words "You won't see this key again." If you are not an admin, ask an admin to make a key for you.

Step 2: Check the key

First, keep the key in a file, so it never sits in a command:

  1. Make a file named riff-key.txt in your home folder with TextEdit or Notepad. Put only the key in it, and save it as plain text.
  2. On macOS and Linux, run chmod 600 ~/riff-key.txt in a terminal, so only you can read it.

Keep this file out of your call-log folder: you back that folder up, and the key must not go with it.

Then open a terminal, put the key in the RIFF_API_KEY environment variable, and ask the API for your newest call. Use the block for your computer. On Windows, the terminal is usually PowerShell; its prompt starts with PS.

Shell
# macOS and Linux (Terminal)
export RIFF_API_KEY="$(cat ~/riff-key.txt)"
curl -H "Authorization: Bearer $RIFF_API_KEY" "https://api.chattychapters.com/api/v1/calls?limit=1"
PowerShell
# Windows (PowerShell)
$env:RIFF_API_KEY = Get-Content ~/riff-key.txt
curl.exe -H "Authorization: Bearer $env:RIFF_API_KEY" "https://api.chattychapters.com/api/v1/calls?limit=1"
Command Prompt
:: Windows (Command Prompt)
set /p RIFF_API_KEY=<"%USERPROFILE%\riff-key.txt"
curl -H "Authorization: Bearer %RIFF_API_KEY%" "https://api.chattychapters.com/api/v1/calls?limit=1"

You should see JSON that starts with {"data": [. That is your newest call. If your business has no calls yet, you see {"data": [], "next_cursor": null}: make a demo call on whappai.com, claim it in the gear, and try again. If you see invalid_key, the key is not right: see Troubleshooting. Keep this terminal open for the next steps.

Step 3: Get the script

Choose Download call_log.py at the top of the script's box, or choose Copy and paste the script into a new file named call_log.py. Put it in the folder where you want your call log. You do not need to change anything in it.

examples/call_log.pyDownload call_log.py
"""call_log.py: copy your Riff calls into a folder you keep.

It makes a folder (call-log, or the name you give) with:
  calls.csv              one row per call, newest first. It opens in Excel and Google Sheets.
  transcripts/<id>.txt   what was said on each call, one line per turn ("Riff: ..." or "Caller: ...").
  .state.json            what the script already has, so the next run fetches only new and changed calls.

Put your key alone in a file, riff-key.txt in your home folder, then run it.
  macOS and Linux:       RIFF_API_KEY="$(cat ~/riff-key.txt)" python3 call_log.py
  Windows (PowerShell):  $env:RIFF_API_KEY = Get-Content ~/riff-key.txt
                         py call_log.py

It needs Python 3.9 or newer and nothing else. Riff keeps a call for 30 days; this folder keeps it for as long as
you like. Guide: https://whappai.com/developers-call-log.html
"""
import csv
import http.client
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone

BASE = os.environ.get("RIFF_API_BASE", "https://api.chattychapters.com/api/v1").rstrip("/")
KEY = os.environ.get("RIFF_API_KEY", "").strip()
# The script names itself, so its requests are easy to pick out in logs (and a filter that refuses
# Python's default "Python-urllib/3.x" agent, 403 error 1010, lets it through).
USER_AGENT = "riff-call-log/1.0 (+https://whappai.com/developers-call-log.html)"
TIMEOUT = 30                                   # seconds to wait for one answer
RETRIES = 5                                    # how many times to wait and try again when the API is busy
REFETCH_ON = ("call.ended", "call.claimed")    # events that mean "this call is new or changed"
COLUMNS = ["id", "started_at", "duration_seconds", "kind", "line", "caller_name", "caller_phone", "need", "outcome",
           "booking_start", "booking_end", "address", "urgency", "transcript_file"]


class ApiError(Exception):
    """The API said no. `code` is the error code from the answer, like invalid_key."""


def get(path, params):
    """GET one API path and return its JSON answer. On 429 or 503, wait Retry-After seconds and try again."""
    query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})   # skip empty parameters
    url = BASE + path + ("?" + query if query else "")
    # The key goes in this header only. Never put it in the URL: URLs end up in logs.
    headers = {"Authorization": "Bearer " + KEY, "Accept": "application/json", "User-Agent": USER_AGENT}
    request = urllib.request.Request(url, headers=headers)
    for attempt in range(RETRIES + 1):
        try:
            with urllib.request.urlopen(request, timeout=TIMEOUT) as answer:
                return json.load(answer)
        except urllib.error.HTTPError as err:
            try:
                body = json.loads(err.read()).get("error", {})    # {"code": ..., "message": ...}
            except ValueError:                                   # not the API's JSON (a proxy's error page)
                body = {}
            if err.code in (429, 503) and attempt < RETRIES:
                retry_after = err.headers.get("Retry-After", "2")
                wait = int(retry_after) if retry_after.isdigit() else 2
                print(f"The API is busy ({err.code} {body.get('code')}). Waiting {wait} seconds.")
                time.sleep(wait)
                continue
            error = ApiError(f"{err.code} {body.get('code')}: {body.get('message')} "
                             f"(request id {err.headers.get('X-Request-Id')})")
            error.code = body.get("code")
            raise error


def walk_feed(after):
    """Read the event feed to its end. Return the new cursor, the ids of calls that ended or were claimed, and
    how many turns each ended call's transcript has (the call.ended event says so)."""
    ids, turns = set(), {}
    while True:
        page = get("/events", {"limit": 100, "after": after})
        ids.update(e["call_id"] for e in page["data"] if e["type"] in REFETCH_ON)
        turns.update((e["call_id"], e["data"]["turns"]) for e in page["data"] if e["type"] == "call.ended")
        after = page["next_cursor"] or after
        if not page["data"]:
            return after, ids, turns


def list_all_calls():
    """Every call of the last 30 days with its transcript, 20 per request (the most expand=transcript allows)."""
    calls, cursor = [], None
    while True:
        page = get("/calls", {"expand": "transcript", "limit": 20, "cursor": cursor})
        calls += page["data"]
        cursor = page["next_cursor"]
        if not cursor:
            return calls


def get_call(call_id):
    """One call with its transcript, or None when the API no longer has it (it passed 30 days)."""
    try:
        return get("/calls/" + urllib.parse.quote(call_id, safe=""), {"expand": "transcript"})
    except ApiError as err:
        if err.code == "not_found":
            return None
        raise


def is_complete(call, expected):
    """True when the transcript is in. It can be empty for a few seconds after a call ends, so an empty one is
    tried again next run. `expected` is the turn count from the call's call.ended event: 0 means nobody spoke.
    When the feed did not say (None), an empty transcript counts as final an hour after the call."""
    if call["transcript"]["turns"] or expected == 0:
        return True
    ended = datetime.fromisoformat(call["ended_at"].replace("Z", "+00:00"))
    return expected is None and datetime.now(timezone.utc) - ended > timedelta(hours=1)


def display_phone(phone):
    """+15125550147 becomes +1 512-555-0147. Spreadsheets keep this as text; a bare +1512... can turn into a number."""
    if phone and len(phone) == 12 and phone.startswith("+1") and phone[1:].isdigit():
        return f"+1 {phone[2:5]}-{phone[5:8]}-{phone[8:]}"
    return phone or ""


def file_name(call_id):
    """A safe file name for a call id (letters, digits, - and _ only)."""
    return "".join(c if c.isalnum() or c in "-_" else "_" for c in call_id)


def load_state(folder):
    """What earlier runs saved, or an empty state on the first run."""
    try:
        with open(os.path.join(folder, ".state.json"), encoding="utf-8") as f:
            state = json.load(f)
    except FileNotFoundError:
        return {"cursor": None, "calls": {}, "complete": {}, "turns": {}}
    except ValueError:
        sys.exit(f"{os.path.join(folder, '.state.json')} is damaged. Rename it and run again: "
                 "the script then copies every call again, and your other files stay.")
    state.setdefault("turns", {})
    return state


def save_state(folder, state):
    """Write the state to a temporary file, then swap it in, so a crash never leaves half a file."""
    path = os.path.join(folder, ".state.json")
    with open(path + ".tmp", "w", encoding="utf-8") as f:
        json.dump(state, f, ensure_ascii=False)
    os.replace(path + ".tmp", path)


def write_transcript(folder, call):
    """Write transcripts/<id>.txt and return its path inside the folder, or "" when nobody spoke yet."""
    turns = call.get("transcript", {}).get("turns", [])
    if not turns:
        return ""
    name = "transcripts/" + file_name(call["id"]) + ".txt"
    with open(os.path.join(folder, name), "w", encoding="utf-8") as f:
        f.writelines(("Riff" if t["speaker"] == "agent" else "Caller") + ": " + t["text"] + "\n" for t in turns)
    return name


def write_csv(folder, calls):
    """calls.csv, newest first. UTF-8 with a BOM and every field in quotes, so Excel reads it correctly."""
    rows = sorted(calls, key=lambda c: (c["started_at"] or c["ended_at"], c["id"]), reverse=True)
    with open(os.path.join(folder, "calls.csv.tmp"), "w", encoding="utf-8-sig", newline="") as f:
        out = csv.writer(f, quoting=csv.QUOTE_ALL)
        out.writerow(COLUMNS)
        for c in rows:
            booking, details = c["booking"] or {}, c["details"]
            out.writerow([c["id"], c["started_at"], c["duration_seconds"], c["kind"], display_phone(c["line"]),
                          c["caller"]["name"], display_phone(c["caller"]["phone"]), c["need"], c["outcome"],
                          booking.get("start"), booking.get("end"), details.get("address"), details.get("urgency"),
                          c.get("transcript_file", "")])
    os.replace(os.path.join(folder, "calls.csv.tmp"), os.path.join(folder, "calls.csv"))


def fetch(state):
    """Ask the API for what is new. Return the new feed cursor and the calls to store."""
    if state["cursor"] is None:
        print("First run: copying every call from the last 30 days.")
        # Take the feed cursor FIRST. A call that ends while we page through the list is then in the feed
        # after this cursor, and the next run fetches it. Nothing falls in the gap.
        cursor, _, turns = walk_feed(None)
        state["turns"].update(turns)
        return cursor, list_all_calls()
    print("Checking for new and changed calls.")
    cursor, ids, turns = walk_feed(state["cursor"])
    state["turns"].update(turns)
    ids.update(i for i, done in state["complete"].items() if not done)   # transcripts not in yet
    fetched = []
    for call_id in sorted(ids):
        call = get_call(call_id)
        if call is None:
            print(f"gone     {call_id} is past 30 days; your copy stays in the folder.")
            state["complete"][call_id] = True
        else:
            fetched.append(call)
    return cursor, fetched


def main():
    """Fetch what is new, update the folder, save where we stopped."""
    # A name the console cannot show (Windows, output to a file) prints as "?" instead of stopping the script.
    getattr(sys.stdout, "reconfigure", lambda **k: None)(errors="replace")
    folder = sys.argv[1] if len(sys.argv) > 1 else "call-log"
    if not KEY:
        sys.exit("Set RIFF_API_KEY to your key first. See Step 2 of the guide.")
    os.makedirs(os.path.join(folder, "transcripts"), exist_ok=True)
    state = load_state(folder)
    try:
        cursor, fetched = fetch(state)
    except ApiError as err:
        hint = " Check RIFF_API_KEY." if err.code == "invalid_key" else ""
        sys.exit(f"The API refused the request: {err}.{hint}")
    except (OSError, http.client.HTTPException) as err:     # no connection, a timeout, a dropped answer
        reason = getattr(err, "reason", None) or err
        hint = ("Your Python cannot check the API's certificate: see Troubleshooting in the guide."
                if "CERTIFICATE_VERIFY_FAILED" in str(reason) else "Check your internet connection, then run it again.")
        sys.exit(f"Could not reach the API at {BASE}: {reason}. {hint}")
    changed = 0
    for call in fetched:
        old = state["calls"].get(call["id"])
        call["transcript_file"] = write_transcript(folder, call)
        status = "new" if old is None else ("same" if old == call else "changed")
        changed += status != "same"
        state["calls"][call["id"]] = call
        state["complete"][call["id"]] = is_complete(call, state["turns"].get(call["id"]))
        when = (call["started_at"] or call["ended_at"])[:16].replace("T", " ")
        who, need = call["caller"]["name"] or "Unknown caller", call["need"] or "need not known"
        print(f"{status:<8} {when}  {call['outcome']:<10}  {who}: {need}")
    try:
        write_csv(folder, state["calls"].values())
    except PermissionError:
        sys.exit("Could not replace calls.csv. Close it in Excel, then run the script again.")
    state["cursor"] = cursor
    save_state(folder, state)
    print(f"{len(state['calls'])} calls in {os.path.join(folder, 'calls.csv')} ({changed} new or changed)")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit("Stopped. Nothing is lost: run it again to continue.")

You should see call_log.py in your folder. The script uses only Python itself, so there is nothing else to install.

Step 4: Run it

In the same terminal, go to the folder with call_log.py and run it. On Windows, type py in place of python3.

Shell
python3 call_log.py

You should see one line for each call, then a count:

Text
First run: copying every call from the last 30 days.
new      2026-10-05 14:02  booked      Dana Ortiz: Kitchen sink — leaking under the cabinet
new      2026-10-05 11:40  emergency   Sam Patel: Water heater — leaking from the bottom
new      2026-10-04 22:15  no_booking  Unknown caller: need not known
new      2026-10-04 18:03  no_booking  Unknown caller: Toilet — running all night
4 calls in call-log/calls.csv (4 new or changed)

Each line tells you:

  1. new: the call is new to your folder. Later runs can also say changed or same.
  2. When the call started, in UTC.
  3. How it ended: booked, emergency or no_booking.
  4. Who called and what they needed.

If your business has no calls yet, you see 0 calls in call-log/calls.csv (0 new or changed). Make a demo call on whappai.com, claim it in the gear, and run the script again.

The script gets 20 calls in each request, with their transcripts. So a month of calls takes only a few seconds.

Step 5: Open calls.csv

Open call-log/calls.csv in Excel, or in Google Sheets (File, Import, Upload). You should see one row for each call, the newest call at the top, and the 14 columns from What you will build.

Phone numbers look like +1 512-555-0147. That is on purpose. The API sends E.164 form, +15125550147. A spreadsheet can change a bare number like that into 15125550147 or 1.51E+10. The space and the dashes keep it as text.

Times are in UTC, as the API sends them. booking_start and booking_end carry your business's UTC offset. A spreadsheet shows these times as text. They still sort correctly: sort the column A to Z for oldest first.

transcript_file names the call's transcript, like transcripts/web-trial-862861dca69e41079dd1bcd6b864992d.txt. Each line of a transcript starts with Riff: or Caller:. The cell is empty when nobody spoke.

Note: Close calls.csv in Excel before the next run. Windows does not let the script replace a file that Excel has open.

Step 6: Run it again

Run the same command again:

Shell
python3 call_log.py

You should see only the calls that are new or changed since the last run. Here, one call came in:

Text
Checking for new and changed calls.
new      2026-10-06 15:28  no_booking  Lee Chen: Faucet — dripping
5 calls in call-log/calls.csv (1 new or changed)

When nothing happened, you see 0 new or changed. Why so fast? The script saved its place in the event feed in .state.json. It asks the API only for events after that place. Then it fetches only the calls that those events name.

Step 7: Run it every 15 minutes

On macOS and Linux, use cron. Put your call-log folder in your home folder: macOS does not let cron open the Desktop or Documents. Run which python3 and note its answer, like /usr/local/bin/python3. Then type crontab -e and add this line. Put your folder in place of /Users/you/call-log-folder, and the answer of which python3 in place of /usr/local/bin/python3.

Shell
*/15 * * * * cd /Users/you/call-log-folder && RIFF_API_KEY="$(cat ~/riff-key.txt)" /usr/local/bin/python3 call_log.py >> call_log.txt 2>&1

On Windows, first save the key as a user environment variable: Start, type "environment variables", choose Edit environment variables for your account, then New, name RIFF_API_KEY, and paste the key as the value. Then open Task Scheduler and make a basic task that repeats every 15 minutes and runs py call_log.py in your folder.

You should see call_log.txt gain a few lines every 15 minutes, and new calls appear in calls.csv. A computer that sleeps runs nothing; the next run catches up, and nothing is lost.

Warning: riff-key.txt holds your key. Never put the key itself in the cron line: cron writes each command line to the system log. If someone else saw the key, revoke it in the gear and make a new one.

How it works

  1. First run. The script reads the event feed to its end and takes the feed's cursor first. Then it pages through GET /calls?expand=transcript&limit=20 until next_cursor is null. A call that ends while it pages is in the feed after that cursor, so the next run fetches it. No call falls in the gap.
  2. Later runs. The script reads the feed from the cursor in .state.json. On call.ended or call.claimed, it fetches that call with GET /calls/{id}?expand=transcript.
  3. Transcripts that are not in yet. A transcript can be empty for a few seconds after a call ends. The feed's call.ended event says how many turns each call has. A call with 0 turns is complete at once: nobody spoke. A call whose transcript is not in yet is marked not complete in .state.json, and the script fetches it again on every run until it is.
  4. A busy API. On 429 rate_limited or 503 unavailable, the script waits the number of seconds in the Retry-After header, then tries again, up to 5 times.
  5. Safe saving. The script writes calls.csv and .state.json to a temporary file first, then swaps it in. If the computer stops in the middle, or you press Ctrl+C, the old files stay whole, and the next run does the work again.
  6. A long pause. After a long pause, a later run fetches one call per request, at most 60 a minute. A long gap is slow, not lossy.
  7. Your key. The script reads the key from RIFF_API_KEY and sends it only in the Authorization header. It never prints the key and never writes it to a file. It names itself in the User-Agent header, so its requests are easy to pick out in logs.

The reference explains each route: the event feed, paging and errors.

Keep calls longer than 30 days

Riff deletes a call 30 days after it ends (see the 30-day rule). Your folder does not. When the API no longer has a call, the script keeps your copy and prints gone. So your folder is your archive. Back it up like your other business records.

Troubleshooting

What you seeWhat to do
401 invalid_keyThe key is wrong, revoked or not set in this terminal. Do Step 2 again in the same terminal. If you lost the key, make a new one.
Set RIFF_API_KEY to your key firstThe terminal does not have your key. Do Step 2 again in the same terminal.
0 calls in call-log/calls.csvYour business has no calls in the last 30 days. Make a demo call on whappai.com, claim it in the gear, and run the script again.
403 with error code: 1010A network filter between you and the API refused your HTTP client's User-Agent. Set your own User-Agent, as this script and the examples do.
The API is busy (429 rate_limited)Each key may make 60 requests a minute. The script waits and tries again by itself. If it stops with 429, another app uses the same key a lot: wait a minute and run the script again.
CERTIFICATE_VERIFY_FAILEDPython from python.org on a Mac cannot check websites until you run Install Certificates.command once. Open the Python folder in Applications and double-click it.
Could not reach the APICheck your internet connection, then run the script again.
Could not replace calls.csv. Close it in ExcelExcel has calls.csv open. Close it, then run the script again.
.state.json is damagedRename .state.json (for example to old-state.json) and run the script again. It copies every call again; your other files stay.
A call has an empty transcript_fileNobody spoke on the call, or the transcript is not in yet. The script fetches it again on every run until it is in.
400 unknown_parameter with Unknown parameter '…'The API refuses a parameter that it does not know. This script sends only known parameters. If you changed the script, check the names in the reference.
Operation not permitted in call_log.txt (macOS cron)Cron cannot open the Desktop or Documents. Move the call-log folder to your home folder and change the cron line.
python3: command not found, or py is not recognizedInstall Python from python.org. On Windows, tick "Add python.exe to PATH" in the installer, then type py in place of python3.

Every error that the API sends has a request id. Quote it when you ask for help, and never send your key.

Next steps

  • Filters: find one caller's calls, or only booked calls, with GET /calls?phone=….
  • Events: what the script reads under the hood, and how to follow the feed in your own software.
  • Coming next: webhooks will send each event to your server, so you will not need cron.