Open data API
Every event's reactions are public and timestamped, so you can line them up with a recording on YouTube, Vimeo or your own player. No key is needed. CORS is open, and reads are limited to about 2 requests a second per IP. Reactions never reveal who sent them unless the sender chose to show their name.
GET/api/v1/events?status=live|upcoming|past&limit=50
Lists events. The default status is live.
GET/api/v1/events/:id
Event metadata, location, times (ISO 8601, UTC) and the IANA time zone, plus speakers. Speakers linked to an account include their profile, with the avatar's three gradient colours and ink colour. confirmed is true once that person has accepted being listed. speakers_locked is true once the organizer has frozen the speaker list.
{
"id": "x8Kd2LmQ0a",
"title": "Quarterly town hall",
"status": "ended",
"starts_at": "2026-10-02T16:00:00.000Z",
"ends_at": "2026-10-02T17:00:00.000Z",
"timezone": "America/New_York",
"speakers": [{
"id": "p1…", "name": "Ana Ruiz", "added_by": "jm",
"profile": {
"username": "ana_ruiz", "display_name": "Ana Ruiz", "bio": "…",
"avatar": { "colors": ["#F97316", "#DB2777", "#6366F1"], "ink": "#FFFFFF" },
"confirmed": true, "url": "https://…/u/ana_ruiz"
} | null
}]
}GET/api/v1/events/:id/reactions
Every reaction in order. offset_ms is milliseconds since the scheduled start. count is the number of rapid taps folded into one reaction: use it for visual weight, not for tallies.
Parameters: format=json|csv|vtt, limit (up to 5000, JSON only), cursor (JSON only), since and until (ISO timestamps), speaker_id.
{
"event_id": "x8Kd2LmQ0a",
"data": [
{ "id": 1042, "timestamp": "2026-10-02T16:12:31.204Z", "offset_ms": 751204,
"emoji": "❤️", "count": 3, "speaker_id": "p1…", "speaker_name": "Ana Ruiz", "username": null }
],
"next_cursor": "eyJ2IjoxLCJpZCI6MTA0Mn0" | null
}Keep following next_cursor until it is null. format=csv streams every row in one download.
emoji is always one of: 👍 Support, 👎 Oppose, ❤️ Strong Support, 😡 Strong Disapproval, 🤔 Unsure, 🤝 Compromise, 💡 Good Idea, ⚠️ Concern, 😂 Ridiculous, ❓ Clarify.
GET/api/v1/events/:id/reactions?format=vtt
A WebVTT caption track you can load in any HTML5 player. It has one cue per window_ms (default 5000) showing the emojis sent in that window and the speaker most of them were for. If your recording starts after the scheduled start, pass offset_ms to shift the track. vtt=metadata gives one JSON cue per reaction, for building your own overlay.
<video src="talk.mp4" controls>
<track kind="captions" default
src="https://say-so.xyz/api/v1/events/x8Kd2LmQ0a/reactions?format=vtt&offset_ms=95000">
</video>GET/api/v1/events/:id/stats
Totals by emoji and by speaker, a bucketed timeline and highlights (most loved, most divisive, peak moment). Each reaction counts once here. show=named or show=anonymous limits it to reactions posted with or without a name (default: all).
GET/api/v1/events/:id/viewers
How many devices had the reaction pad open (several tabs on one device count once), sampled every minute while the event is live. Buckets nobody was watching count as 0. Events from before viewer counts were recorded return no samples.
{
"event_id": "x8Kd2LmQ0a",
"sample_ms": 60000,
"peak": { "offset_ms": 1230000, "timestamp": "2026-10-02T16:20:00.000Z", "viewers": 184 },
"average": 142.6,
"samples": [{ "offset_ms": 0, "timestamp": "2026-10-02T16:00:00.000Z", "viewers": 37 }, …]
}Responses for ended events can be cached for 5 minutes and support If-None-Match.