> Markdown copy of https://whappai.com/developers-call-log.html — for AI agents and plain-text readers.

# 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.

## 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_name | caller_phone | need | outcome |
|---|---|---|---|
| Dana Ortiz | +1 512-555-0147 | Kitchen sink — leaking under the cabinet | booked |
| Sam Patel | +1 512-555-0183 | Water heater — leaking from the bottom | emergency |
|  |  |  | no_booking |
|  | +1 512-555-0129 | Toilet — running all night | no_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](https://whappai.com/login.html) 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`.

```bash
# 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"
```

```bat
:: 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](https://whappai.com/developers-call-log.html#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.py` — [Download call_log.py](https://whappai.com/examples/call_log.py)

```python
"""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`.

```bash
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](https://whappai.com/developers-call-log.html#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:

```bash
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`.

```bash
*/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](https://whappai.com/developers.html) explains each route: [the event feed](https://whappai.com/developers.html#stay-up-to-date), [paging](https://whappai.com/developers.html#paging) and [errors](https://whappai.com/developers.html#errors).

## Keep calls longer than 30 days

Riff deletes a call 30 days after it ends (see [the 30-day rule](https://whappai.com/developers.html#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 see | What to do |
|---|---|
| `401 invalid_key` | The 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 first` | The terminal does not have your key. Do Step 2 again in the same terminal. |
| `0 calls in call-log/calls.csv` | Your 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: 1010` | A 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_FAILED` | Python 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 API` | Check your internet connection, then run the script again. |
| `Could not replace calls.csv. Close it in Excel` | Excel has `calls.csv` open. Close it, then run the script again. |
| `.state.json is damaged` | Rename `.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_file` | Nobody 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](https://whappai.com/developers.html#paging). |
| `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 recognized` | Install 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](https://whappai.com/developers.html#filters): find one caller's calls, or only booked calls, with `GET /calls?phone=…`.
- [Events](https://whappai.com/developers.html#events): what the script reads under the hood, and how to follow the feed in your own software.
- [Coming next](https://whappai.com/developers.html#coming-next): webhooks will send each event to your server, so you will not need cron.
