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.
- Make a key. Sign in on whappai.com, open the gear in the header, open the API tab and choose Create key. Copy the key.
- On the web page, paste the key in the box below. Every example on the page then holds your key. This step is optional.
- 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.
Paste a key to put it into the examples on this page. It stays in this tab and is forgotten when you leave.
The examples now contain your key. Copy single commands, not the page.
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
"https://api.chattychapters.com/api/v1/calls?limit=2"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))// 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 shows every field.
Want every call in a spreadsheet? Build your call log 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.
- Sign in on whappai.com and open the gear in the header.
- Open the API tab and choose Create key.
- Name the key, like "Zapier", so you can tell your apps apart.
- 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:
- Create a new key, with a name that says it is the new one.
- Switch your integration to the new key, and check that it works.
- 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 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
/callsYour 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. |
Errors
| Status | Codes |
|---|---|
| 400 | bad_request, unknown_parameter, key_in_url |
| 401 | invalid_key |
| 405 | method_not_allowed |
| 429 | rate_limited |
| 500 | internal_error |
| 503 | unavailable |
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
"https://api.chattychapters.com/api/v1/calls?limit=2"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))// 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));{
"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"
}Get one call
/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. |
Errors
| Status | Codes |
|---|---|
| 400 | bad_request, unknown_parameter, key_in_url |
| 401 | invalid_key |
| 404 | not_found |
| 405 | method_not_allowed |
| 429 | rate_limited |
| 500 | internal_error |
| 503 | unavailable |
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
"https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d"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))// 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));{
"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
}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. |
recording | Always null in this release. |
transcript | Only when you ask for expand=transcript. See 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.
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/callsimport 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))// 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
/calls/{id}/transcriptWhat 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. |
Errors
| Status | Codes |
|---|---|
| 400 | unknown_parameter, key_in_url |
| 401 | invalid_key |
| 404 | not_found |
| 405 | method_not_allowed |
| 429 | rate_limited |
| 500 | internal_error |
| 503 | unavailable |
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
"https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d/transcript"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))// 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));{
"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."
}
]
}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 first. Skip types you do not know.
List one call's events
/calls/{id}/eventsWhat 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. |
Errors
| Status | Codes |
|---|---|
| 400 | bad_request, unknown_parameter, key_in_url |
| 401 | invalid_key |
| 404 | not_found |
| 405 | method_not_allowed |
| 429 | rate_limited |
| 500 | internal_error |
| 503 | unavailable |
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
"https://api.chattychapters.com/api/v1/calls/web-trial-862861dca69e41079dd1bcd6b864992d/events"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))// 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));{
"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
}Follow the event feed
/eventsEvery 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. |
Errors
| Status | Codes |
|---|---|
| 400 | bad_request, unknown_parameter, key_in_url |
| 401 | invalid_key |
| 405 | method_not_allowed |
| 429 | rate_limited |
| 500 | internal_error |
| 503 | unavailable |
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
"https://api.chattychapters.com/api/v1/events?after=eyJpZCI6MTA0NiwiayI6ImV2ZW50cyJ9"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))// 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));{
"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"
}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=…answersUnknown parameter 'after'. /calls pages with 'cursor'.GET /events?cursor=…answersUnknown 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.
{"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_limitedwith aRetry-Afterheader. Wait that many seconds, then try again. - Requests with invalid keys are limited by address: 30 requests in a minute.
- On 503
unavailable, also waitRetry-Afterseconds. - 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 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 is the map of these docs, in the llmstxt.org format.
- developers.md and developers-call-log.md are these pages as Markdown, with the same content.
- 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, with no key.
Get this contract
/openapi.jsonThis 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.
Errors
| Status | Codes |
|---|---|
| 400 | key_in_url |
| 405 | method_not_allowed |
| 500 | internal_error |
curl "https://api.chattychapters.com/api/v1/openapi.json"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))// 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));{
"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"
}
}
}
}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.
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.
recordingisnulluntil 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 /callstakes the filtersuntil,outcome,kindandphone, next tosince.expand=transcriptonGET /calls(at most 20 calls a page) and onGET /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 carryX-RateLimit-LimitandX-RateLimit-Remaining. - Phones (
caller.phone,details.phone) are+1and 10 digits, ornull. outcomecomes from the booking first, then the urgency.- An unknown parameter answers 400
unknown_parameterand names it. GET /openapi.jsonserves the contract, with no key. These docs have Markdown copies and anllms.txt.
2026-10-06
- API v1:
GET /calls,GET /calls/{id},GET /calls/{id}/events,GET /calls/{id}/transcriptandGET /events. Admins make keys in the gear on whappai.com. Calls follow the 30-day rule.