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.

  1. In the admin portal, go to Settings > API Keys
  2. Click New API key
  3. Enter a Name that describes the integration that will use it, for example “ERP sync”
  4. Under Permissions, check only what the integration needs — every permission is off by default
  5. Click Create API key
  6. 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:

Response error-401.json Download
{
"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 a type, an id, its attributes, and relationships that point to other records by type and id.
  • 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 null rather than missing. The one exception is personal data your key isn’t permitted to read, which is left out.
GET /api/v1/conversations

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:

GET /api/v1/conversations

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, and last URLs. next is null on the last page. On a page requested with page[after], prev and last are null.
  • meta — page, per_page, total_count, and total_pages when the page was requested with page[number]; only per_page when it was requested with page[after], since no count is made. Some endpoints add their own meta keys on top of these, such as meta.close_reasons on conversations or meta.custom_fields on 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:

  1. On the first run, follow links.next through everything and save each record, keyed by its id
  2. Remember the newest updated_at you received
  3. On the next run, pass that value as filter[updated_since] and follow links.next through the results
  4. 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.

Response error-400.json Download
{
"errors": [
{
"status": "400",
"title": "Bad Request",
"detail": "Unknown filter[status] value(s): archived"
}
]
}
Response error-403.json Download
{
"errors": [
{
"status": "403",
"title": "Forbidden",
"detail": "This API key does not have the `conversations` permission."
}
]
}
Response error-404.json Download
{
"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


© 2024-2026 Stoked — Real conversations. Real trust.