Stoked API
A read-only JSON API for pulling your community’s Stoked data into your own database, data warehouse, or internal tools.
This section is written for developers. If you just need a spreadsheet, the CSV exports in the admin portal (Exporting Conversations) need no code.
Version 1 is in early access. It covers Conversations and their messages, Advocates, Prospects, Custom Fields, Waivers, the Message Log, and the Reply Speed report.
Getting an API key
API keys are created by a community admin. Each key belongs to one community and can only read that community’s data.
- In the admin portal, go to Settings > API Keys
- Click New API key
- Enter a Name that describes the integration that will use it, for example “ERP sync”
- Under Permissions, check only what the integration needs — every permission is off by default
- Click Create API key
- Copy the key from the Your new API key box and store it somewhere safe, like a password manager or your secrets store
The key is shown once. Stoked stores only a hash of it, so nobody — including Stoked support — can show it to you again. If you lose it, revoke it and create a new one.
Create one key per integration so you can revoke them independently. The API Keys list shows each key’s permissions, who created it, and when it was last used.
- Edit permissions changes what a key can read without changing the key itself, so a running integration keeps working. When new endpoints ship, you check a box rather than rotating the key.
- Revoke stops a key working immediately and can’t be undone.
Authentication
Send the key as a Bearer token on every request:
Authorization: Bearer YOUR_API_KEY
Keys start with stk_live_. Treat a key like a password: keep it on your server, never in a browser, mobile app, or public repository.
A missing, mistyped, or revoked key returns 401:
{
"errors": [
{
"status": "401",
"title": "Unauthorized",
"detail": "Invalid or missing API key."
}
]
}
Permissions
A key can read only what it has been granted. A request to an endpoint the key lacks permission for returns 403, and the error names the missing permission.
| Permission | Grants |
|---|---|
conversations |
Read conversations and their messages |
advocates |
Read advocate records, without personal data |
advocates:pii |
Adds advocate names, email, phone, display name and location, address, map coordinates, and custom fields |
prospects |
Read prospect records, without personal data |
prospects:pii |
Adds prospect names, email, and phone |
analytics |
Read analytics events, aggregates, reports, and the reply speed ranking |
waivers |
Read signed waivers, including the signer’s name and email, and waiver templates |
message_log |
Read the message log: every message sent or received, with its delivery status, without addresses |
message_log:pii |
Adds the phone number or email address each message went from and to |
A :pii permission adds attributes to an endpoint; it doesn’t open one. advocates:pii without advocates still returns 403. Without the :pii permission, personal attributes are left out of the response rather than set to null.
Records point to each other with links.related URLs — a conversation links to its advocate and prospect, for example. A link is a pointer, not a grant: following it with a key that lacks that endpoint’s permission returns 403.
Making requests
The base URL is:
https://integrations.stokedhq.com/api/v1
- Every endpoint is a
GET. The API is read-only. - Responses follow the JSON:API format with the media type
application/vnd.api+json. Each record has atype, anid, itsattributes, andrelationshipsthat point to other records bytypeandid. - IDs are opaque strings. Store them as text, not numbers. They are case-insensitive: the API returns them in lower case, while CSV exports and admin portal URLs show the same ID in upper case. Compare them case-insensitively, and either case works in a URL or filter.
- Timestamps are ISO 8601 in UTC, to the whole second:
2026-09-06T17:30:00Z. - Attributes with no value are
nullrather than missing. The one exception is personal data your key isn’t permitted to read, which is left out.
cURL
curl -G "https://integrations.stokedhq.com/api/v1/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: application/vnd.api+json" \
--data-urlencode "page[size]=100"
Ruby
require "net/http"
require "json"
uri = URI("https://integrations.stokedhq.com/api/v1/conversations")
uri.query = URI.encode_www_form("page[size]" => "100")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_KEY"
request["Accept"] = "application/vnd.api+json"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
puts JSON.parse(response.body)["data"]
Python
import requests
response = requests.get(
"https://integrations.stokedhq.com/api/v1/conversations",
headers={
"Authorization": "Bearer YOUR_API_KEY",
"Accept": "application/vnd.api+json",
},
params={"page[size]": "100"},
)
response.raise_for_status()
print(response.json()["data"])
C#
using System.Net.Http.Headers;
using System.Text.Json;
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY");
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.api+json");
using var response = await client.GetAsync("https://integrations.stokedhq.com/api/v1/conversations?page[size]=100");
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine(document.RootElement.GetProperty("data"));
JavaScript
const url = new URL("https://integrations.stokedhq.com/api/v1/conversations");
url.searchParams.set("page[size]", "100");
const response = await fetch(url, {
headers: {
Authorization: "Bearer YOUR_API_KEY",
Accept: "application/vnd.api+json",
},
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
const { data } = await response.json();
console.log(data);
Your API key
Every sample in these docs uses the placeholder YOUR_API_KEY. Replace it with a key from Settings → API Keys in your admin portal.
A key can read personal data about your advocates and prospects. Keep it on your server: never put it in browser code, a mobile app or a public repository. If one leaks, revoke it and create another.
Pagination
List endpoints return one page at a time. Request the first page and keep following links.next until it is null:
cURL
# Requires jq. Writes one JSON object per line to conversations.jsonl.
url="https://integrations.stokedhq.com/api/v1/conversations?page[size]=200"
while [ "$url" != "null" ]; do
page=$(curl -sg "$url" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: application/vnd.api+json")
echo "$page" | jq -c '.data[]' >> conversations.jsonl
url=$(echo "$page" | jq -r '.links.next')
done
Ruby
require "net/http"
require "json"
url = "https://integrations.stokedhq.com/api/v1/conversations?page[size]=200"
conversations = []
while url
uri = URI(url)
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_KEY"
request["Accept"] = "application/vnd.api+json"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
page = JSON.parse(response.body)
conversations.concat(page["data"])
url = page.dig("links", "next")
end
puts "Fetched #{conversations.size} conversations"
Python
import requests
url = "https://integrations.stokedhq.com/api/v1/conversations?page[size]=200"
headers = {
"Authorization": "Bearer YOUR_API_KEY",
"Accept": "application/vnd.api+json",
}
conversations = []
while url:
response = requests.get(url, headers=headers)
response.raise_for_status()
page = response.json()
conversations.extend(page["data"])
url = page["links"]["next"]
print(f"Fetched {len(conversations)} conversations")
C#
using System.Net.Http.Headers;
using System.Text.Json;
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY");
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.api+json");
string? url = "https://integrations.stokedhq.com/api/v1/conversations?page[size]=200";
var conversations = new List<JsonElement>();
while (url is not null)
{
using var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
using var page = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
foreach (var item in page.RootElement.GetProperty("data").EnumerateArray())
{
conversations.Add(item.Clone());
}
url = page.RootElement.GetProperty("links").GetProperty("next").GetString();
}
Console.WriteLine($"Fetched {conversations.Count} conversations");
JavaScript
let url = "https://integrations.stokedhq.com/api/v1/conversations?page[size]=200";
const conversations = [];
while (url) {
const response = await fetch(url, {
headers: {
Authorization: "Bearer YOUR_API_KEY",
Accept: "application/vnd.api+json",
},
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
const page = await response.json();
conversations.push(...page.data);
url = page.links.next;
}
console.log(`Fetched ${conversations.length} conversations`);
| Parameter | Default | Description |
|---|---|---|
page[size] |
50 |
Records per page. The maximum is 200; a larger value is treated as 200. |
page[after] |
A cursor copied from a previous response’s links.next. Returns the records after that point. |
|
page[number] |
1 |
A page to jump to, starting at 1. Cannot be combined with page[after]. |
links.next always carries a page[after] cursor, whichever way you asked for the page. A cursor names the exact record the page ended on, so a record that changes while you are reading moves to the end of the list and is delivered later in the same run: nothing is skipped, and the run ends when next is null.
page[number] is for jumping to a page you can count to, for example to sample the data. Counting pages yourself is not safe for a full read, because a record that changes mid-run shifts the ones behind it across a page boundary.
Each list response includes:
links—self,first,prev,next, andlastURLs.nextisnullon the last page. On a page requested withpage[after],prevandlastarenull.meta—page,per_page,total_count, andtotal_pageswhen the page was requested withpage[number]; onlyper_pagewhen it was requested withpage[after], since no count is made. Some endpoints add their ownmetakeys on top of these, such asmeta.close_reasonson conversations ormeta.custom_fieldson advocates — see the endpoint’s own page.
Syncing incrementally
Lists are returned in a fixed order: least recently updated first (updated_at ascending). Sorting is not configurable. Records that change move to the end of the list, so new activity always arrives last.
To keep your own copy up to date:
- On the first run, follow
links.nextthrough everything and save each record, keyed by itsid - Remember the newest
updated_atyou received - On the next run, pass that value as
filter[updated_since]and followlinks.nextthrough the results - Insert or update each record by
id
filter[updated_since] is inclusive, so you will receive the record at the boundary a second time. Updating by id makes the repeat harmless.
Follow links.next rather than counting pages: a record that changes while you are reading is delivered later in the same run instead of slipping between pages. See Pagination.
Each endpoint’s page lists what causes a record’s updated_at to change.
Matching the CSV exports
The admin portal’s CSV exports and the API describe the same records with a few differences in spelling. If you are moving from one to the other:
| CSV export | API | Note |
|---|---|---|
ulid column, upper case |
id, lower case |
The same value. Compare case-insensitively. |
status as ACTIVE |
status as active |
Lower case throughout the API |
conversation_count |
conversations_count |
|
TRUE / FALSE |
true / false |
JSON booleans |
| Advocates export lists active advocates only | Advocates list contains every status, deleted and erased advocates included | Filter with filter[status]=active to match |
custom_field_<identifier> columns |
custom_fields object keyed by identifier |
An unanswered field is an empty cell in the CSV and null in the API |
wallet_1_name, wallet_1_balance_raw, wallet_1_lifetime_raw, … columns |
wallets array of {id, name, unit_name, currency_code, balance, lifetime} |
One array entry per wallet instead of numbered columns. The API gives the raw numbers, not the formatted 125 points. |
| Waivers Agreement, Browser, User Agent | agreement_plain_text, browser, user_agent |
The same values. agreement has the agreement as HTML. |
Message Log recipient as Name (+1…), trigger as Message sent → prospect |
recipient relationship plus to_address; trigger as message_sent_notification_to_prospect |
The API gives the raw values; the CSV shows labels |
Reply Speed first_reply_median, follow_up_median, composite as 12 min / 1.5 hr |
first_reply_median_seconds, follow_up_median_seconds, composite_seconds in seconds; advocate_name and status come from the advocate link |
The API gives the seconds; the CSV rounds |
Errors
Errors use standard HTTP status codes and a JSON:API errors array. Each error has a status, a title, and a human-readable detail.
| Status | Meaning |
|---|---|
400 Bad Request |
An unknown or malformed query parameter (only filter[...] and page[...] are accepted), an invalid or empty filter value |
401 Unauthorized |
The API key is missing, invalid, or revoked |
403 Forbidden |
The key doesn’t have the permission this endpoint requires |
404 Not Found |
No record with that ID exists in your community |
429 Too Many Requests |
The key has exceeded the rate limit |
500 Internal Server Error |
Something went wrong on our side. Retry later, and contact support if it continues. |
Unknown parameters are rejected rather than ignored, so a typo in a filter name fails loudly instead of quietly returning unfiltered data.
{
"errors": [
{
"status": "400",
"title": "Bad Request",
"detail": "Unknown filter[status] value(s): archived"
}
]
}
{
"errors": [
{
"status": "403",
"title": "Forbidden",
"detail": "This API key does not have the `conversations` permission."
}
]
}
{
"errors": [
{
"status": "404",
"title": "Not Found",
"detail": "Resource not found."
}
]
}
Rate limits
Each key can make 120 requests per minute. Every response authenticated with an API key — including a 403 from a missing permission — carries two headers so you can pace yourself before you hit the limit:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed per minute for this key (120) |
X-RateLimit-Remaining |
Requests left in the current minute |
(A 401 for a missing or invalid key carries neither — the limit is per key, and no key was identified.)
Beyond the limit, requests return 429 with a Retry-After header giving the number of seconds left in the current window.
At the maximum page size, 120 requests is 24,000 records per minute, so a well-behaved sync rarely reaches the limit. If yours does, wait for the Retry-After period and continue from the same URL.
There is also a per-IP-address backstop of 600 requests per minute across all keys. A single key can’t reach it; several keys or workers sharing one outbound address can. That 429 carries only Retry-After — the per-IP backstop isn’t tied to a single key, so it has no X-RateLimit-* headers to report.
Versioning and stability
The version is part of the URL (/api/v1). While v1 is in early access we may add endpoints, attributes, and filters at any time, so write your integration to ignore fields it doesn’t recognize. We’ll announce breaking changes in What’s New.
In this section