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

# Your calls, in your own software.

The Riff API gives your software the calls Riff answers for your business: who called, their number, what they needed, how the call ended, the booked job, every event and the transcript. It is read-only and answers JSON.

## Quickstart

You need a key and one command. Your first request takes about a minute.

1. **Make a key.** Sign in on [whappai.com](https://whappai.com/login.html), open the gear in the header, open the **API** tab and choose **Create key**. Copy the key.
2. **On the web page, paste the key in the box below.** Every example on the page then holds your key. This step is optional.
3. **Run the command.** You should see your two newest calls, newest first. 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 run the command again.

```bash
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
  "https://api.chattychapters.com/api/v1/calls?limit=2"
```

```python
import json
import os
import urllib.request

key = os.environ.get("RIFF_API_KEY", "rk_live_YOUR_KEY")
request = urllib.request.Request(
    "https://api.chattychapters.com/api/v1/calls?limit=2",
    # Name your app, so its requests are easy to find in logs.
    headers={"Authorization": "Bearer " + key, "User-Agent": "my-call-app/1.0"},
)
with urllib.request.urlopen(request) as answer:
    print(json.dumps(json.load(answer), indent=2))
```

```javascript
// Save as first.mjs, then run: node first.mjs
const key = process.env.RIFF_API_KEY ?? "rk_live_YOUR_KEY";
const answer = await fetch("https://api.chattychapters.com/api/v1/calls?limit=2", {
  headers: { Authorization: `Bearer ${key}` },
});
console.log(JSON.stringify(await answer.json(), null, 2));
```

The answer is JSON. `data` holds the calls. `next_cursor` points to the next page. [List calls](https://whappai.com/developers.html#list-calls) shows every field.

Want every call in a spreadsheet? [Build your call log](https://whappai.com/developers-call-log.html) in about 10 minutes.

## Authentication

Send your key in the `Authorization` header on every request: `Authorization: Bearer rk_live_YOUR_KEY`. Keys start with `rk_live_`. Every example on this page uses `rk_live_YOUR_KEY` in its place.

The base URL is `https://api.chattychapters.com/api/v1`.

Never put the key in the URL. URLs end up in logs, so the API refuses a key in the URL with 400 `key_in_url`. If a key was ever in a link, treat it as leaked and revoke it.

### Make a key

Only an admin of your organization can make keys.

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, like "Zapier", so you can tell your apps apart.
4. Copy the key from the panel. You will not see it again. Riff keeps only a fingerprint of it, so we cannot show it to you later. If you lose it, revoke it and make a new one.

To stop an app, choose **Revoke** beside its key. The key stops working on the very next request.

### Rotating a key

Rotation needs no special step. Do these three things in this order:

1. Create a new key, with a name that says it is the new one.
2. Switch your integration to the new key, and check that it works.
3. Revoke the old key.

A revoke takes effect on the next request, so switch first. If you revoke first, your integration stops until you give it the new key.

> **Warning:** Treat a key like a password. Keep it on your server, in an environment variable or in a file only you can read. Never put it in a web page you publish, an app you ship, a link or a chat. Every key sees all of your organization's calls.

## Build your call log

Do you want every call in a spreadsheet that you keep? The [Build your call log](https://whappai.com/developers-call-log.html) tutorial gives you one Python script. Run it with your key, and you get `calls.csv` and a transcript file for each call. Run it again, and only new and changed calls arrive. It takes about 10 minutes, and you need no other software.

## Calls

A call is one ended call to your Riff line, or a demo call that you claimed in the gear. The API lists the same calls, in the same order, as the Calls tab in the gear. It never lists a call that is still in progress.

### List calls

`GET /calls`

Your organization's ended calls, newest first, from the last 30 days. Filters combine (AND). To get every call, follow `next_cursor`: send it as `cursor` with the same filters until `next_cursor` is `null`. Add `expand=transcript` to get each call's transcript in the same answer (then at most 20 calls a page). A call still in progress is not listed.

#### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | integer | no | How many calls in one page: 1 to 100, default 50. With expand=transcript the most is 20. A larger number is treated as the most. |
| `cursor` | string | no | The `next_cursor` from the page before. It means "everything older than the last call of that page": send the same filters with it. |
| `since` | string (date-time) | no | Only calls that started at or after this time (inclusive). ISO 8601 with a UTC offset; a time with no offset is read as UTC. |
| `until` | string (date-time) | no | Only calls that started before this time (exclusive). ISO 8601 with a UTC offset; a time with no offset is read as UTC. |
| `outcome` | string | no | Only calls with this outcome. One of: `booked`, `emergency`, `no_booking`. |
| `kind` | string | no | Only calls to your line (`line`), or only demo calls you claimed (`demo`). |
| `phone` | string | no | Only calls from this caller. Any format with at least 10 digits: `(512) 555-0147`, `5125550147` and `+15125550147` find the same calls (the last 10 digits are compared). Fewer than 10 digits is a 400 `bad_request`. |
| `expand` | string | no | `transcript` adds each call's `transcript` to the answer, so you need no extra request per call. |

#### Example request

```bash
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
  "https://api.chattychapters.com/api/v1/calls?limit=2"
```

```python
import json
import os
import urllib.request

key = os.environ.get("RIFF_API_KEY", "rk_live_YOUR_KEY")
request = urllib.request.Request(
    "https://api.chattychapters.com/api/v1/calls?limit=2",
    # Name your app, so its requests are easy to find in logs.
    headers={"Authorization": f"Bearer {key}", "User-Agent": "riff-docs-example/1.0"},
)
with urllib.request.urlopen(request) as response:
    print(json.dumps(json.load(response), indent=2))
```

```javascript
// Node 18 or later. Save as example.mjs, then run: node example.mjs
const key = process.env.RIFF_API_KEY ?? "rk_live_YOUR_KEY";
const response = await fetch("https://api.chattychapters.com/api/v1/calls?limit=2", {
  headers: { Authorization: `Bearer ${key}` },
});
console.log(JSON.stringify(await response.json(), null, 2));
```

#### Sample answer

GET /calls?limit=2 — a first page with more to come

```json
{
  "data": [
    {
      "id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "started_at": "2026-10-05T14:02:11.482913+00:00",
      "ended_at": "2026-10-05T14:05:23.907114+00:00",
      "duration_seconds": 192,
      "line": "+15126411499",
      "kind": "line",
      "caller": {
        "name": "Dana Ortiz",
        "phone": "+15125550147"
      },
      "need": "Kitchen sink — leaking under the cabinet",
      "outcome": "booked",
      "booking": {
        "start": "2026-10-07T09:00:00-05:00",
        "end": "2026-10-07T10:00:00-05:00"
      },
      "details": {
        "address": "603 W Cesar Chavez St, Austin, TX 78701",
        "name": "Dana Ortiz",
        "phone": "+15125550147",
        "problem": "Kitchen sink — leaking under the cabinet",
        "urgency": "this week"
      },
      "deleted_after": "2026-11-04T14:05:23.907114+00:00",
      "recording": null
    },
    {
      "id": "web-trial-8af9e2f2ea4f43c3adee12a5f86cc661",
      "started_at": "2026-10-05T11:40:02.118305+00:00",
      "ended_at": "2026-10-05T11:42:47.520991+00:00",
      "duration_seconds": 165,
      "line": "+15126411499",
      "kind": "demo",
      "caller": {
        "name": "Sam Patel",
        "phone": "+15125550183"
      },
      "need": "Water heater — leaking from the bottom",
      "outcome": "emergency",
      "booking": null,
      "details": {
        "address": "1100 Congress Ave, Austin, TX 78701",
        "name": "Sam Patel",
        "phone": "+15125550183",
        "problem": "Water heater — leaking from the bottom",
        "urgency": "emergency — water on the floor"
      },
      "deleted_after": "2026-11-04T11:42:47.520991+00:00",
      "recording": null
    }
  ],
  "next_cursor": "eyJpZCI6IndlYi10cmlhbC04YWY5ZTJmMmVhNGY0M2MzYWRlZTEyYTVmODZjYzY2MSIsImsiOiJjYWxscyIsInQiOiIyMDI2LTEwLTA1VDExOjQwOjAyLjExODMwNSswMDowMCJ9"
}
```

#### Errors

| Status | Codes |
| --- | --- |
| 400 | [`bad_request`](https://whappai.com/developers.html#error-bad_request), [`unknown_parameter`](https://whappai.com/developers.html#error-unknown_parameter), [`key_in_url`](https://whappai.com/developers.html#error-key_in_url) |
| 401 | [`invalid_key`](https://whappai.com/developers.html#error-invalid_key) |
| 405 | [`method_not_allowed`](https://whappai.com/developers.html#error-method_not_allowed) |
| 429 | [`rate_limited`](https://whappai.com/developers.html#error-rate_limited) |
| 500 | [`internal_error`](https://whappai.com/developers.html#error-internal_error) |
| 503 | [`unavailable`](https://whappai.com/developers.html#error-unavailable) |

### Get one call

`GET /calls/{id}`

One call, with the same fields as in the list. Add `expand=transcript` to get its transcript too. A call that is not yours, or older than 30 days, answers 404 `not_found`.

#### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes (path) | The call's `id` from `GET /calls`. |
| `expand` | string | no | `transcript` adds each call's `transcript` to the answer, so you need no extra request per call. |

#### Example request

```bash
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
  "https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d"
```

```python
import json
import os
import urllib.request

key = os.environ.get("RIFF_API_KEY", "rk_live_YOUR_KEY")
request = urllib.request.Request(
    "https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d",
    # Name your app, so its requests are easy to find in logs.
    headers={"Authorization": f"Bearer {key}", "User-Agent": "riff-docs-example/1.0"},
)
with urllib.request.urlopen(request) as response:
    print(json.dumps(json.load(response), indent=2))
```

```javascript
// Node 18 or later. Save as example.mjs, then run: node example.mjs
const key = process.env.RIFF_API_KEY ?? "rk_live_YOUR_KEY";
const response = await fetch("https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d", {
  headers: { Authorization: `Bearer ${key}` },
});
console.log(JSON.stringify(await response.json(), null, 2));
```

#### Sample answer

GET /calls/web-trial-862861dca69e41079dd1bcd6b864992d

```json
{
  "id": "web-trial-862861dca69e41079dd1bcd6b864992d",
  "started_at": "2026-10-05T14:02:11.482913+00:00",
  "ended_at": "2026-10-05T14:05:23.907114+00:00",
  "duration_seconds": 192,
  "line": "+15126411499",
  "kind": "line",
  "caller": {
    "name": "Dana Ortiz",
    "phone": "+15125550147"
  },
  "need": "Kitchen sink — leaking under the cabinet",
  "outcome": "booked",
  "booking": {
    "start": "2026-10-07T09:00:00-05:00",
    "end": "2026-10-07T10:00:00-05:00"
  },
  "details": {
    "address": "603 W Cesar Chavez St, Austin, TX 78701",
    "name": "Dana Ortiz",
    "phone": "+15125550147",
    "problem": "Kitchen sink — leaking under the cabinet",
    "urgency": "this week"
  },
  "deleted_after": "2026-11-04T14:05:23.907114+00:00",
  "recording": null
}
```

#### Errors

| Status | Codes |
| --- | --- |
| 400 | [`bad_request`](https://whappai.com/developers.html#error-bad_request), [`unknown_parameter`](https://whappai.com/developers.html#error-unknown_parameter), [`key_in_url`](https://whappai.com/developers.html#error-key_in_url) |
| 401 | [`invalid_key`](https://whappai.com/developers.html#error-invalid_key) |
| 404 | [`not_found`](https://whappai.com/developers.html#error-not_found) |
| 405 | [`method_not_allowed`](https://whappai.com/developers.html#error-method_not_allowed) |
| 429 | [`rate_limited`](https://whappai.com/developers.html#error-rate_limited) |
| 500 | [`internal_error`](https://whappai.com/developers.html#error-internal_error) |
| 503 | [`unavailable`](https://whappai.com/developers.html#error-unavailable) |

### What a call carries

| Field | Meaning |
|---|---|
| `id` | The call's id. Web and demo calls look like `web-trial-` plus 32 hex digits. Treat it as an opaque string. |
| `started_at`, `ended_at`, `duration_seconds` | When the call began and ended (UTC, ISO 8601), and how long it ran in whole seconds. `started_at` and `duration_seconds` can be `null`; `ended_at` is never `null`. |
| `line` | The number the caller dialed (your Riff line), in E.164 form, like `+15126411499`. Can be `null`. |
| `kind` | `line` for a call to your business line. `demo` for a demo call you claimed. |
| `caller` | `name` as the caller gave it, and `phone` as `+1` and 10 digits. Each is `null` when Riff did not get it. |
| `need` | What the caller needed, in a few words, like `Water heater — not heating`. `null` when Riff did not learn it. |
| `outcome` | `booked` when a job was booked. Else `emergency` when the caller said it was an emergency. Else `no_booking`. Never `null`. |
| `booking` | The booked job's `start` and `end` (`end` can be `null`), or `null` when nothing was booked. |
| `details` | Every detail Riff captured, by name, like `name`, `phone`, `address`, `urgency`, `problem`. Read the names you know and keep the rest. |
| `deleted_after` | When Riff deletes the call. Can be `null`. See [the 30-day rule](https://whappai.com/developers.html#the-30-day-rule). |
| `recording` | Always `null` in this release. |
| `transcript` | Only when you ask for `expand=transcript`. See [Transcripts](https://whappai.com/developers.html#transcripts). |

## Filters

`GET /calls` takes five filters. They combine: a call must match all of them.

| Filter | Only calls that… | Example |
|---|---|---|
| `since` | started at or after this time | `2026-10-01T00:00:00+00:00` |
| `until` | started before this time | `2026-10-06T00:00:00+00:00` |
| `outcome` | ended this way: `booked`, `emergency` or `no_booking` | `booked` |
| `kind` | came to your line (`line`), or are demo calls you claimed (`demo`) | `line` |
| `phone` | came from this caller. Any format with at least 10 digits works. The last 10 digits are compared. | `(512) 555-0147` |

`since` and `until` make the range `[since, until)`: a call that started at `since` is in it, and a call that started at `until` is not. So one day ends exactly where the next day starts, and no call is counted twice. Write times in ISO 8601 with a UTC offset. A time with no offset is read as UTC.

A cursor means everything older than this call. It does not remember your filters. So send the same filters with the cursor on every page. If you change a filter, start again with no cursor.

```bash
curl -G -H "Authorization: Bearer rk_live_YOUR_KEY" \
  --data-urlencode "outcome=booked" \
  --data-urlencode "phone=(512) 555-0147" \
  https://api.chattychapters.com/api/v1/calls
```

```python
import json
import os
import urllib.parse
import urllib.request

key = os.environ.get("RIFF_API_KEY", "rk_live_YOUR_KEY")
query = urllib.parse.urlencode({"outcome": "booked", "phone": "(512) 555-0147"})
request = urllib.request.Request(
    "https://api.chattychapters.com/api/v1/calls?" + query,
    # Name your app, so its requests are easy to find in logs.
    headers={"Authorization": "Bearer " + key, "User-Agent": "my-call-app/1.0"},
)
with urllib.request.urlopen(request) as answer:
    print(json.dumps(json.load(answer), indent=2))
```

```javascript
// Save as booked.mjs, then run: node booked.mjs
const key = process.env.RIFF_API_KEY ?? "rk_live_YOUR_KEY";
const query = new URLSearchParams({ outcome: "booked", phone: "(512) 555-0147" });
const answer = await fetch(`https://api.chattychapters.com/api/v1/calls?${query}`, {
  headers: { Authorization: `Bearer ${key}` },
});
console.log(JSON.stringify(await answer.json(), null, 2));
```

A value that is not allowed answers 400 `bad_request`. The message names the parameter and the allowed values, like `outcome must be one of: booked, emergency, no_booking.` A `phone` with fewer than 10 digits answers `phone must have at least 10 digits.`

## Transcripts

### Get one call's transcript

`GET /calls/{id}/transcript`

What was said on one call, in order. `speaker` is `agent` (Riff) or `caller`. The answer can be `{"turns": []}` for a few seconds after `call.ended`: if it is empty, fetch it again. To get many transcripts in one request, use `GET /calls?expand=transcript`.

#### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes (path) | The call's `id` from `GET /calls`. |

#### Example request

```bash
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
  "https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d/transcript"
```

```python
import json
import os
import urllib.request

key = os.environ.get("RIFF_API_KEY", "rk_live_YOUR_KEY")
request = urllib.request.Request(
    "https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d/transcript",
    # Name your app, so its requests are easy to find in logs.
    headers={"Authorization": f"Bearer {key}", "User-Agent": "riff-docs-example/1.0"},
)
with urllib.request.urlopen(request) as response:
    print(json.dumps(json.load(response), indent=2))
```

```javascript
// Node 18 or later. Save as example.mjs, then run: node example.mjs
const key = process.env.RIFF_API_KEY ?? "rk_live_YOUR_KEY";
const response = await fetch("https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d/transcript", {
  headers: { Authorization: `Bearer ${key}` },
});
console.log(JSON.stringify(await response.json(), null, 2));
```

#### Sample answer

GET /calls/web-trial-862861dca69e41079dd1bcd6b864992d/transcript

```json
{
  "turns": [
    {
      "speaker": "agent",
      "text": "Thanks for calling Example Plumbing Business. This is Riff. How can I help?"
    },
    {
      "speaker": "caller",
      "text": "Hi, my kitchen sink is leaking under the cabinet."
    },
    {
      "speaker": "agent",
      "text": "I'm sorry to hear that. Can I get your first and last name?"
    },
    {
      "speaker": "caller",
      "text": "Dana Ortiz."
    },
    {
      "speaker": "agent",
      "text": "Thanks, Dana. What is the address for the job?"
    },
    {
      "speaker": "caller",
      "text": "603 West Cesar Chavez Street."
    },
    {
      "speaker": "agent",
      "text": "I have Wednesday, October 7th at 9 AM or 11 AM. Which works better?"
    },
    {
      "speaker": "caller",
      "text": "9 works."
    },
    {
      "speaker": "agent",
      "text": "You're booked for Wednesday, October 7th at 9 AM. We'll see you then."
    }
  ]
}
```

#### Errors

| Status | Codes |
| --- | --- |
| 400 | [`unknown_parameter`](https://whappai.com/developers.html#error-unknown_parameter), [`key_in_url`](https://whappai.com/developers.html#error-key_in_url) |
| 401 | [`invalid_key`](https://whappai.com/developers.html#error-invalid_key) |
| 404 | [`not_found`](https://whappai.com/developers.html#error-not_found) |
| 405 | [`method_not_allowed`](https://whappai.com/developers.html#error-method_not_allowed) |
| 429 | [`rate_limited`](https://whappai.com/developers.html#error-rate_limited) |
| 500 | [`internal_error`](https://whappai.com/developers.html#error-internal_error) |
| 503 | [`unavailable`](https://whappai.com/developers.html#error-unavailable) |

### Many transcripts in one request

Add `expand=transcript` to `GET /calls` or `GET /calls/{id}`. Each call then carries its `transcript`, so you need no extra request per call. With `expand=transcript`, a page holds at most 20 calls: a `limit` above 20 is treated as 20. So 100 calls cost 5 requests, not 101.

A transcript can be `{"turns": []}` for a few seconds after `call.ended`. If it is empty, fetch it again.

## Events

An event is one thing that happened on a call. There are five types:

| Type | When | `data` |
|---|---|---|
| `call.started` | The call began. | `dialed`: the number the caller dialed. |
| `call.detail_captured` | Riff captured one detail. There is one event per detail. | `field` and `value`, like `address`. |
| `call.appointment_booked` | Riff booked a job on your calendar. | `booking` and `calendar`. |
| `call.ended` | The call ended. | `turns`: how many turns the transcript has. |
| `call.claimed` | A demo call was claimed into your organization. | Always `{}`. |

Riff records details when the call ends. So `call.detail_captured` and `call.appointment_booked` carry the end time, the same time as `call.ended`.

Events keep raw values. A phone in `call.detail_captured` is as the caller gave it, like `5125550147`. Calls carry normalized phones: `caller.phone` and `details.phone` are `+1` and 10 digits, or `null`.

### Stay up to date

To keep your own copy up to date, read `GET /events` and store its `next_cursor`. Next time, send it as `after`. You get only new events, and none twice.

Re-fetch a call on `call.ended` or `call.claimed`: fetch it with `GET /calls/{id}`. The feed trails real time by about 5 seconds, so a call can appear in the feed a few seconds after it ends.

New event types are announced in the [Changelog](https://whappai.com/developers.html#changelog) first. Skip types you do not know.

### List one call's events

`GET /calls/{id}/events`

What happened on one call, oldest first: `call.started`, one `call.detail_captured` per detail, `call.appointment_booked` when a job was booked, `call.ended`, and `call.claimed` when a demo call was claimed. Riff records details when the call ends, so `call.detail_captured` and `call.appointment_booked` carry the end time. Follow `next_cursor` as `cursor` until it is `null`.

#### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes (path) | The call's `id` from `GET /calls`. |
| `limit` | integer | no | How many events in one page: 1 to 100, default 50. A larger number is treated as 100. |
| `cursor` | string | no | The `next_cursor` from the page before. |

#### Example request

```bash
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
  "https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d/events"
```

```python
import json
import os
import urllib.request

key = os.environ.get("RIFF_API_KEY", "rk_live_YOUR_KEY")
request = urllib.request.Request(
    "https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d/events",
    # Name your app, so its requests are easy to find in logs.
    headers={"Authorization": f"Bearer {key}", "User-Agent": "riff-docs-example/1.0"},
)
with urllib.request.urlopen(request) as response:
    print(json.dumps(json.load(response), indent=2))
```

```javascript
// Node 18 or later. Save as example.mjs, then run: node example.mjs
const key = process.env.RIFF_API_KEY ?? "rk_live_YOUR_KEY";
const response = await fetch("https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d/events", {
  headers: { Authorization: `Bearer ${key}` },
});
console.log(JSON.stringify(await response.json(), null, 2));
```

#### Sample answer

GET /calls/web-trial-862861dca69e41079dd1bcd6b864992d/events

```json
{
  "data": [
    {
      "id": "1041",
      "type": "call.started",
      "version": 1,
      "occurred_at": "2026-10-05T14:02:11.482913+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "dialed": "+15126411499"
      }
    },
    {
      "id": "1042",
      "type": "call.detail_captured",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "field": "address",
        "value": "603 W Cesar Chavez St, Austin, TX 78701"
      }
    },
    {
      "id": "1043",
      "type": "call.detail_captured",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "field": "name",
        "value": "Dana Ortiz"
      }
    },
    {
      "id": "1044",
      "type": "call.detail_captured",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "field": "phone",
        "value": "5125550147"
      }
    },
    {
      "id": "1045",
      "type": "call.detail_captured",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "field": "problem",
        "value": "Kitchen sink — leaking under the cabinet"
      }
    },
    {
      "id": "1046",
      "type": "call.detail_captured",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "field": "urgency",
        "value": "this week"
      }
    },
    {
      "id": "1047",
      "type": "call.appointment_booked",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "booking": {
          "event_id": "5k2v9q1d8h7m3n4p6r0s",
          "slot_id": "slot-0900",
          "offer_set_id": "ofs-7c41e2",
          "start": "2026-10-07T09:00:00-05:00",
          "end": "2026-10-07T10:00:00-05:00",
          "status": "confirmed"
        },
        "calendar": {
          "kind": "live",
          "label": null
        }
      }
    },
    {
      "id": "1048",
      "type": "call.ended",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "turns": 9
      }
    }
  ],
  "next_cursor": null
}
```

#### Errors

| Status | Codes |
| --- | --- |
| 400 | [`bad_request`](https://whappai.com/developers.html#error-bad_request), [`unknown_parameter`](https://whappai.com/developers.html#error-unknown_parameter), [`key_in_url`](https://whappai.com/developers.html#error-key_in_url) |
| 401 | [`invalid_key`](https://whappai.com/developers.html#error-invalid_key) |
| 404 | [`not_found`](https://whappai.com/developers.html#error-not_found) |
| 405 | [`method_not_allowed`](https://whappai.com/developers.html#error-method_not_allowed) |
| 429 | [`rate_limited`](https://whappai.com/developers.html#error-rate_limited) |
| 500 | [`internal_error`](https://whappai.com/developers.html#error-internal_error) |
| 503 | [`unavailable`](https://whappai.com/developers.html#error-unavailable) |

### Follow the event feed

`GET /events`

Every event of every call in your organization, oldest first, to keep your own system up to date. Store the `next_cursor` you get and send it as `after` next time: you get only new events, none twice. When there is nothing new, `data` is empty and `next_cursor` is the cursor you sent. The feed trails real time by about 5 seconds. On `call.ended` or `call.claimed`, fetch the call with `GET /calls/{id}`. Event `data` keeps raw values (a phone is not normalized). The feed keeps events for 30 days, like the calls.

#### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | integer | no | How many events in one page: 1 to 100, default 50. A larger number is treated as 100. |
| `after` | string | no | The `next_cursor` from your last `/events` answer. You get only events after it. Leave it out to start from the oldest event. |

#### Example request

```bash
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
  "https://api.chattychapters.com/api/v1/events?after=eyJpZCI6MTA0NiwiayI6ImV2ZW50cyJ9"
```

```python
import json
import os
import urllib.request

key = os.environ.get("RIFF_API_KEY", "rk_live_YOUR_KEY")
request = urllib.request.Request(
    "https://api.chattychapters.com/api/v1/events?after=eyJpZCI6MTA0NiwiayI6ImV2ZW50cyJ9",
    # Name your app, so its requests are easy to find in logs.
    headers={"Authorization": f"Bearer {key}", "User-Agent": "riff-docs-example/1.0"},
)
with urllib.request.urlopen(request) as response:
    print(json.dumps(json.load(response), indent=2))
```

```javascript
// Node 18 or later. Save as example.mjs, then run: node example.mjs
const key = process.env.RIFF_API_KEY ?? "rk_live_YOUR_KEY";
const response = await fetch("https://api.chattychapters.com/api/v1/events?after=eyJpZCI6MTA0NiwiayI6ImV2ZW50cyJ9", {
  headers: { Authorization: `Bearer ${key}` },
});
console.log(JSON.stringify(await response.json(), null, 2));
```

#### Sample answer

GET /events?after=eyJpZCI6MTA0NiwiayI6ImV2ZW50cyJ9

```json
{
  "data": [
    {
      "id": "1047",
      "type": "call.appointment_booked",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "booking": {
          "event_id": "5k2v9q1d8h7m3n4p6r0s",
          "slot_id": "slot-0900",
          "offer_set_id": "ofs-7c41e2",
          "start": "2026-10-07T09:00:00-05:00",
          "end": "2026-10-07T10:00:00-05:00",
          "status": "confirmed"
        },
        "calendar": {
          "kind": "live",
          "label": null
        }
      }
    },
    {
      "id": "1048",
      "type": "call.ended",
      "version": 1,
      "occurred_at": "2026-10-05T14:05:23.907114+00:00",
      "call_id": "web-trial-862861dca69e41079dd1bcd6b864992d",
      "data": {
        "turns": 9
      }
    },
    {
      "id": "1049",
      "type": "call.claimed",
      "version": 1,
      "occurred_at": "2026-10-05T14:20:09.311458+00:00",
      "call_id": "web-trial-8af9e2f2ea4f43c3adee12a5f86cc661",
      "data": {}
    }
  ],
  "next_cursor": "eyJpZCI6MTA0OSwiayI6ImV2ZW50cyJ9"
}
```

#### Errors

| Status | Codes |
| --- | --- |
| 400 | [`bad_request`](https://whappai.com/developers.html#error-bad_request), [`unknown_parameter`](https://whappai.com/developers.html#error-unknown_parameter), [`key_in_url`](https://whappai.com/developers.html#error-key_in_url) |
| 401 | [`invalid_key`](https://whappai.com/developers.html#error-invalid_key) |
| 405 | [`method_not_allowed`](https://whappai.com/developers.html#error-method_not_allowed) |
| 429 | [`rate_limited`](https://whappai.com/developers.html#error-rate_limited) |
| 500 | [`internal_error`](https://whappai.com/developers.html#error-internal_error) |
| 503 | [`unavailable`](https://whappai.com/developers.html#error-unavailable) |

## Paging

A list answers `{"data": [...], "next_cursor": ...}`. `limit` sets the page size: 1 to 100, default 50. With `expand=transcript` the most is 20.

Two routes page with `cursor`. One route pages with `after`:

| Route | Order | Send `next_cursor` back as | You have them all when |
|---|---|---|---|
| `GET /calls` | newest first | `cursor` | `next_cursor` is `null` |
| `GET /calls/{id}/events` | oldest first | `cursor` | `next_cursor` is `null` |
| `GET /events` | oldest first | `after` | `data` is empty. `next_cursor` is then the cursor you sent: keep it for next time. |

If you mix them up, the API answers 400 `unknown_parameter` and says which one to use:

- `GET /calls?after=…` answers `Unknown parameter 'after'. /calls pages with 'cursor'.`
- `GET /events?cursor=…` answers `Unknown parameter 'cursor'. /events pages with 'after'.`

## Errors

Every error uses the HTTP status and this shape. Act on `code`. The `message` is for people and can change.

```json
{"error": {"code": "invalid_key", "message": "The API key is missing or not valid. Send it as 'Authorization: Bearer <key>'."}}
```

| Status | Code | Meaning | Example message |
| --- | --- | --- | --- |
| 400 | `bad_request` | A parameter value is not valid; the message names the parameter and the allowed values. | `outcome must be one of: booked, emergency, no_booking.` |
| 400 | `unknown_parameter` | The message is `Unknown parameter '<name>'.`; when you send `after` to /calls or /calls/{id}/events it adds ` /calls pages with 'cursor'.`, and when you send `cursor` to /events it adds ` /events pages with 'after'.` | `Unknown parameter 'after'. /calls pages with 'cursor'.` |
| 400 | `key_in_url` | A key was sent in the URL. | `Send the key in the Authorization header, never in the URL (URLs are logged).` |
| 401 | `invalid_key` | The Authorization header is missing, or the key is malformed, unknown or revoked. The answer is the same in every case. | `The API key is missing or not valid. Send it as 'Authorization: Bearer <key>'.` |
| 404 | `not_found` | No such call, or the call is another organization's, or it is older than 30 days. The answer is the same in every case. | `No such resource.` |
| 405 | `method_not_allowed` | The API is read-only; use GET. | `This API is read-only: use GET.` |
| 429 | `rate_limited` | More than 60 requests in a minute with this key, or more than 30 requests in a minute with invalid keys from your address. Wait `Retry-After` seconds. | `Rate limit exceeded: 60 requests per minute per key.` |
| 500 | `internal_error` | Something failed on our side. Try again; if it keeps failing, send us the `X-Request-Id`. | `Something went wrong on our side.` |
| 503 | `unavailable` | The service is busy for a moment. Wait `Retry-After` seconds and try again. | `The service is busy. Try again in a few seconds.` |

Every answer carries an `X-Request-Id` header. Quote `X-Request-Id` when you ask for help: it lets us find your request, and you never need to send us your key.

A 403 with the text `error code: 1010` does not come from the API. A network filter between you and the API refused your HTTP client's User-Agent. Set your own User-Agent, as the Python examples on this page do.

## Limits and headers

- Each key may make 60 requests a minute. Past that you get 429 `rate_limited` with a `Retry-After` header. Wait that many seconds, then try again.
- Requests with invalid keys are limited by address: 30 requests in a minute.
- On 503 `unavailable`, also wait `Retry-After` seconds.
- A page holds at most 100 items, or 20 calls with `expand=transcript`.
- Answers are never cached (`Cache-Control: no-store`). Ask again for fresh data.
- The API is read-only. Any method but GET answers 405 `method_not_allowed`.

| Header | Type | Sent with | Description |
| --- | --- | --- | --- |
| `X-Request-Id` | string | every answer | A random id for this request. Quote it when you ask for help. |
| `Cache-Control` | string | every answer | Answers are never cached. Ask again for fresh data. |
| `X-RateLimit-Limit` | integer | 200; 400, 404, 429, 503 when the key is valid | Requests allowed per minute for this key (60). |
| `X-RateLimit-Remaining` | integer | 200; 400, 404, 429, 503 when the key is valid | Requests left in the current minute for this key. |
| `Allow` | string | 405 | The only method the API accepts. |
| `Retry-After` | integer | 429, 503 | Seconds to wait before you try again. |

## The 30-day rule

Riff keeps a call, its events and its transcript for 30 days after the call ends, then deletes them. The API follows the same rule exactly: a call older than 30 days is not in the list, `GET /calls/{id}` answers 404 `not_found`, and its events and transcript are gone. Each call's `deleted_after` tells you the day.

If you need a call for longer, copy it into your own system before then. The [call log script](https://whappai.com/developers-call-log.html) does that for you.

## For AI agents

These docs are written for people and for AI agents. An agent does not need a web page:

- [llms.txt](https://whappai.com/llms.txt) is the map of these docs, in the llmstxt.org format.
- [developers.md](https://whappai.com/developers.md) and [developers-call-log.md](https://whappai.com/developers-call-log.md) are these pages as Markdown, with the same content.
- [openapi.json](https://whappai.com/openapi.json) is the contract (OpenAPI 3.1): every route, parameter, answer and error, with examples. The API serves the same file at [https://api.chattychapters.com/api/v1/openapi.json](https://api.chattychapters.com/api/v1/openapi.json), with no key.

### Get this contract

`GET /openapi.json`

This OpenAPI 3.1 document, as JSON. It needs no key. Tools and AI agents can read it to learn every route, parameter, answer and error. The same file is at https://whappai.com/openapi.json.

This route takes no parameters.

#### Example request

```bash
curl "https://api.chattychapters.com/api/v1/openapi.json"
```

```python
import json
import urllib.request

# Name your app, so its requests are easy to find in logs.
request = urllib.request.Request("https://api.chattychapters.com/api/v1/openapi.json", headers={"User-Agent": "riff-docs-example/1.0"})
with urllib.request.urlopen(request) as response:
    print(json.dumps(json.load(response), indent=2))
```

```javascript
// Node 18 or later. Save as example.mjs, then run: node example.mjs
const response = await fetch("https://api.chattychapters.com/api/v1/openapi.json");
console.log(JSON.stringify(await response.json(), null, 2));
```

#### Sample answer

GET /openapi.json (shortened)

```json
{
  "openapi": "3.1.0",
  "info": {
    "title": "Riff API",
    "version": "1.1.0"
  },
  "servers": [
    {
      "url": "https://api.chattychapters.com/api/v1"
    }
  ],
  "paths": {
    "/calls": {
      "get": {
        "operationId": "listCalls",
        "summary": "List calls"
      }
    }
  }
}
```

#### Errors

| Status | Codes |
| --- | --- |
| 400 | [`key_in_url`](https://whappai.com/developers.html#error-key_in_url) |
| 405 | [`method_not_allowed`](https://whappai.com/developers.html#error-method_not_allowed) |
| 500 | [`internal_error`](https://whappai.com/developers.html#error-internal_error) |

### A ready prompt

Before you start, put your key in the `RIFF_API_KEY` environment variable on the computer where the agent runs code. Then give your AI assistant this prompt. Change the last line to your task.

```text
Use the Riff API described at https://whappai.com/llms.txt.
My API key is in the RIFF_API_KEY environment variable. Read it from there,
and send it only in the Authorization header. Never ask me to paste the key
into this chat, never print it, and never write it into a file or into code
you show me. Send your own User-Agent header (not Python's default).
Task: list my booked calls from the last 7 days, with the caller's name,
phone number and booking time.
```

> **Warning:** Never paste a key into a chat with an AI assistant. A chat is stored, so a key in a chat is a leaked key. If you did paste one, revoke it and make a new one.

## Coming next

- Recordings: coming in the next release. `recording` is `null` until then. The link will arrive in this field.
- Webhooks: Riff will send your server each event as it happens, so you do not need to poll the feed.

## Changelog

### 2026-10-07

- `GET /calls` takes the filters `until`, `outcome`, `kind` and `phone`, next to `since`.
- `expand=transcript` on `GET /calls` (at most 20 calls a page) and on `GET /calls/{id}`.
- A new event type, `call.claimed`: a demo call was claimed into your organization.
- Every answer carries `X-Request-Id`. Answers to a valid key carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`.
- Phones (`caller.phone`, `details.phone`) are `+1` and 10 digits, or `null`.
- `outcome` comes from the booking first, then the urgency.
- An unknown parameter answers 400 `unknown_parameter` and names it.
- `GET /openapi.json` serves the contract, with no key. These docs have Markdown copies and an `llms.txt`.

### 2026-10-06

- API v1: `GET /calls`, `GET /calls/{id}`, `GET /calls/{id}/events`, `GET /calls/{id}/transcript` and `GET /events`. Admins make keys in the gear on whappai.com. Calls follow the 30-day rule.
