Event API
Synqframe follows the game in your cloud-composed shows. The event API sends what happens to your own tools, timed to the moment your viewers see it: read the history, keep a socket open, or let Synqframe call your server.
Getting started
The event API comes with the Creator and Team plans. Its events come from shows the cloud composes; a show your own OBS composes has none yet.
In Settings › Workspace › API, a workspace's owners and admins make tokens and add webhooks. A token is shown once, when it is made: keep it somewhere safe, and revoke it there if it gets out. Every request sends it in the Authorization header as Bearer sqf_…. Never put a token in an address: addresses end up in logs. A token is revoked when the person who made it leaves the team, is removed from it, is made a plain member or deletes their account, and it stops working while their account is suspended or archived; the workspace's webhooks stay.
Every answer carries Synqframe-Api-Version: 1. Within version 1, changes only add fields, so ignore the fields you don't know.
The event
Every event, wherever you read it, is the same object. The full JSON Schema is at GET /api/v1/events/schema.
{
"v": 1,
"id": "<event id>",
"seq": 1042,
"workspaceId": "<workspace id>",
"key": "game.GAME_<MOMENT>",
"label": "<the event's name>",
"source": "game",
"categoryId": "<game id>",
"sessionId": "<show id>",
"feed": { "userId": "<user id>", "username": "<username>", "path": "<feed>", "relation": "self" },
"captureTimeMs": 1793620800000,
"synced": true,
"airAtMs": 1793620820000,
"releasedAtMs": 1793620820004,
"late": false,
"lateMs": 0,
"confidence": null,
"round": 7,
"phase": "<phase>",
"match": { "is": "open", "rounds": null, "score": { "ally": 4, "enemy": 2 } },
"payload": {
"kind": "action",
"action": "<action>",
"actor": { "name": "<in-game name>", "character": "<character>", "team": "ally", "me": true },
"target": { "name": "<in-game name>", "character": "<character>", "team": "enemy", "me": false },
"means": { "with": "<what it was done with>", "group": "<its group>", "details": {} },
"involvesMe": true
}
}- id
- Unique in your workspace and stable: use it to drop repeats.
- seq
- Where the event sits in storage, your cursor for reading on. It is not the order events air in.
- key
- Which event this is (see Event keys).
- feed
- Whose game it happened in. relation is self for your own feed and member for a teammate in a team show. Only usernames, never email addresses. null for stream events.
- feeds
- For your own events built from several players at once: every feed involved.
- captureTimeMs
- When it happened in the game, on the shared clock.
- airAtMs
- When viewers see it.
- late, lateMs
- Whether it reached Synqframe after viewers saw it, and by how much.
- synced
- false when the player's clock was not in step, so the times are estimates.
- confidence
- How sure the reading of the screen was, from 0 to 1; null for events read from the action feed.
- round, phase, match
- Where in the match it happened, and the score then.
- payload
- What happened. ally and enemy, and me, are as seen from that feed’s player.
- from
- For your own events: the ids of the events they were built from.
- test
- true on test deliveries from Settings. Never stored.
Event keys
GET /api/v1/events/catalog lists every key your workspace can get, with the fields each has and, where a field takes one of a game's own values, those values. Keys come in families:
- game.GAME_<MOMENT>
- A moment your game defines, scored for the player whose feed it happened on.
- game.<KEY>
- Something read off the screen, such as a banner coming up.
- game.GAME_<ACTION>
- Anyone's action in the action feed, teammates and opponents included.
- custom.<name>
- Your own events, made in Responses › My events: filters, counts, sequences and plays by several players at once.
- stream.<kind>
- Something that happened on your linked channels: a follow, a sub, a cheer, a tip or a raid. It airs as it arrives. A team gets the events of its own channels, and of a member's own channel while that member is in the team's live show. payload.user names who did it by their name on that platform, and is null when they stayed anonymous. Responses to them can show alerts and say things in chat; they can't switch scenes yet.
Which keys exist depends on the games your workspace plays and is defined by Synqframe for each game. A game's key can go away when its definition changes: treat an unknown key as something to skip, not an error.
When events arrive
A show has a delay, so viewers see a moment after it happens. Synqframe holds each event back until its airAtMs and only then sends it, so nothing reaches your tools before viewers see it. History and the socket never return an event that has not aired.
Sometimes a moment is only read after viewers have seen it, for example an action feed line that shows up late. Such an event is still sent, at once, with late: true and lateMs saying by how much. Decide for yourself whether a late event is still worth acting on. (Synqframe's own alerts and chat replies skip events more than 3 seconds late.)
An event whose player's clock was not in step (synced: false) never triggers responses and webhooks do not get it. The history and the socket return it once its moment has passed, with synced: false, so you can tell its times are estimates.
History
GET /api/v1/events returns the events that aired after a cursor, oldest first, from the last 7 days. after is the last seq you have (0 to start), keys a comma-separated list (none: every key) and limit 1 to 500 (100 when left out). Call again with next as after.
GET /api/v1/events?after=0&keys=game.GAME_<MOMENT>,custom.<name>&limit=100
Authorization: Bearer sqf_<token>
200 OK
Synqframe-Api-Version: 1
{ "events": [ ... ], "next": 1042 }Because a later event can air first, a page stops before the first event that has not aired yet, so polling with next never skips one. An event that should have aired over a minute ago but still has not stops holding the page back.
Live socket
Connect to /api/v1/events/ws on this site's address (wss://) with the token in the Authorization header. A browser, which cannot set headers on a socket, offers the subprotocols synqframe and synqframe.token.sqf_… instead.
→ (connect) Authorization: Bearer sqf_<token>
← { "type": "ready", "v": 1, "serverTimeMs": 1793620800000, "workspaceId": "<workspace id>" }
→ { "type": "subscribe", "keys": ["game.GAME_<MOMENT>"], "after": 1042 }
← { "type": "subscribed", "keys": ["game.GAME_<MOMENT>"], "after": 1042 }
← { "type": "event", "event": { ... } }
← { "type": "ping" }
→ { "type": "ping" }
← { "type": "pong" }After ready, send subscribe with the keys you want (none: every key) and, to pick up where you left off, the last seq you saw as after. The server first sends what aired since then, then each event as it airs. Without after you get what airs from now on, including events that happened before you subscribed but air after. Subscribing again replaces the subscription. The server pings after 30 quiet seconds; you may ping too.
- 4401
- No valid token.
- 4403
- The plan has no event API, or events are off. Checked every minute.
- 4404
- The token was revoked (a revoked message comes first).
- 4408
- You fell too far behind (an overflow message comes first). Reconnect with your last seq.
- 4429
- Too many requests. Wait before reconnecting.
On any other close, reconnect after a short wait that grows with each failure, and subscribe with your last seq: nothing is lost while you are away.
Webhooks
A webhook gets one POST for each event it is set to get, sent when the event airs. The address must be https:// on port 443 or 8443, on the public internet, with no user name, password or # part. It is checked when you save it and again before every request.
POST https://your-server.example/events
Content-Type: application/json
Synqframe-Signature: t=1793620820,v1=5f2c…
Synqframe-Delivery: <delivery id>
Synqframe-Event: game.GAME_<MOMENT>
Synqframe-Attempt: 1
{ "deliveryId": "<delivery id>", "event": { ... } }Synqframe-Delivery is the same on every try of one delivery: use it, or the event's id, to handle each one once. Answer with any 2xx status within 5 seconds. Redirects are not followed and count as a failure, as do other statuses, a timeout or a connection that fails.
A failed delivery is tried again 30 seconds, 2 minutes, 10 minutes and 30 minutes later: 5 tries in all, after which it is given up. If the workspace's plan stops including the event API, webhooks pause: nothing is sent, and nothing is saved up to send later. A webhook that fails 50 deliveries in a row pauses itself, and Settings says so; turn it back on once your server is fixed. Recent deliveries in Settings shows each one's tries and the last answer, and Send test sends a signed test event (with test: true) right away.
Checking a signature
Each webhook has a signing secret, shown once when you add the webhook or make a new one. Synqframe-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of t, a dot, and the raw request body, keyed with the secret. For 24 hours after you make a new secret there is a second v1, made with the old one, so you can switch without missing an event.
Check the raw body as it arrived (before parsing it), accept any v1 that matches, compare in constant time, and refuse a t more than 5 minutes from now.
import hashlib, hmac, time
def verify(header: str, secret: str, body: bytes, tolerance: int = 300) -> bool:
fields = [part.split("=", 1) for part in header.split(",") if "=" in part]
stamps = [value for name, value in fields if name == "t"]
if len(stamps) != 1 or not stamps[0].isdigit():
return False
if abs(time.time() - int(stamps[0])) > tolerance:
return False
signed = stamps[0].encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(value, expected) for name, value in fields if name == "v1")// Web Crypto: the same in a browser and on a server.
// body is the raw request body as text, read before parsing it: await request.text().
// Holding bytes instead? Decode them first: new TextDecoder().decode(bytes).
async function verify(header, secret, body, tolerance = 300) {
const fields = header.split(',').map((part) => part.split('=', 2));
const stamps = fields.filter(([name]) => name === 't').map(([, value]) => value);
if (stamps.length !== 1 || !/^\d+$/.test(stamps[0])) return false;
if (Math.abs(Date.now() / 1000 - Number(stamps[0])) > tolerance) return false;
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(stamps[0] + '.' + body));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
// Compare every candidate in full: no early exit on the first differing character.
return fields.some(([name, value]) => name === 'v1' && same(value, expected));
}
function same(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}Limits
- Tokens
- 10 per workspace. Each reads events only.
- Requests
- 600 a minute per token, socket connections included.
- History
- 7 days, up to 500 events a page.
- Webhooks
- 5 per workspace, each with up to 200 event keys or every event.
- Deliveries
- Up to 64 KB each; your answer is read up to 16 KB.
- Tests
- 10 a minute per webhook.
- One socket per use
- Open one socket per tool and reconnect with your cursor; extra sockets get the same events.
Privacy
Events carry what is shown in the game on your stream: the in-game names and characters of the players involved, teammates and opponents included, and the score. They name Synqframe users by username only, never by email address, and the people behind stream events by their name on that platform. Viewers see all of this already, but the API hands it over in a form machines can collect, so only send it to servers you control, and keep what you store no longer than you need it.