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, and Prospects.
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, and reports |
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.
- 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.
| Parameter | Default | Description |
|---|---|---|
page[number] |
1 |
The page to return, starting at 1 |
page[size] |
50 |
Records per page. The maximum is 200; a larger value is treated as 200. |
Each list response includes:
links—self,first,prev,next, andlastURLs.previsnullon the first page andnextisnullon the last page.meta—page,per_page,total_count, andtotal_pages.
To read everything, 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`);
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, page through everything and save each record, keyed by its
id - Remember the newest
updated_atyou received - On the next run, pass that value as
filter[updated_since]and page through 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.
If a record changes while you are paging, it moves to the end of the list and the records after it shift up by one, so a record can slip across a page boundary and be missed in that run. Run your sync when activity is low, use page[size]=200 to keep runs short, and schedule an occasional full sync as a safety net.
Each endpoint’s page lists what causes a record’s updated_at to change.
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. Beyond that, requests return 429 with a Retry-After header giving the number of seconds to wait before trying again.
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.
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