API reference
Webhooks
TwinTone pushes lifecycle events to your server over HTTPS. Use webhooks instead
of polling GET /streams/{id}.
Register a webhook
POST /api/v1/webhooks — scope: webhooks:write
{
"url": "https://your-server.com/twintone/webhook",
"events": ["stream.started", "stream.ended", "stream.error"]
}
Response 201
{
"webhook_id": "…",
"url": "https://your-server.com/twintone/webhook",
"events": ["stream.started", "stream.ended", "stream.error"],
"secret": "whsec_…",
"active": true
}
The signing
secretis returned once, at registration. Store it with the same care as an API key.GET /webhooksnever returns it again.
Events
| Event | Fires when |
|---|---|
stream.started | Status transitions preparing → live. |
stream.ended | Status transitions live → ended. |
stream.error | Stream fails at any point. |
Payload
{
"id": "evt_…",
"type": "stream.started",
"created_at": "2026-08-07T10:00:14.000Z",
"data": {
"stream_id": "a1b2c3d4-…",
"status": "live",
"creator_id": "avt_9ww075quzedr",
"platform": "youtube",
"vertical": "live-commerce"
}
}
Verifying signatures
Every delivery includes a signature header:
X-TwinTone-Signature: t=1754553614,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Compute HMAC_SHA256(secret, "{t}.{raw_body}") and compare to v1 with a
constant-time comparison. Reject any message whose t is more than 5 minutes
old (replay protection).
// "crypto" here is Node's standard hashing library used to compute the HMAC
// signature — it has nothing to do with cryptocurrency.
import crypto from "crypto";
function isValid(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
// Replay protection: reject messages older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(parts.t, 10);
if (age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
// Constant-time comparison, safe on length mismatch
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
} catch {
return false;
}
}
import hashlib, hmac, time
def is_valid(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
# Replay protection: reject messages older than 5 minutes
age = int(time.time()) - int(parts["t"])
if age > 300:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Delivery & retries
- Your endpoint must return a
2xxwithin 10 seconds. - Failures retry up to 5 times with exponential backoff (1m, 5m, 30m, 2h, 6h).
- Events are delivered at least once — make your handler idempotent using
the event
id.
Managing webhooks
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/webhooks | List (no secrets). |
DELETE | /api/v1/webhooks?id={webhook_id} | Delete. |
To rotate a secret: register a new webhook pointing at the same URL, deploy the new secret, then delete the old webhook.
Testing locally
To test deliveries against a local server, expose it with a tunnel and register the public URL as a webhook:
# Option A: ngrok
ngrok http 3000
# → https://abc123.ngrok.app forwards to localhost:3000
# Option B: cloudflared
cloudflared tunnel --url http://localhost:3000
# → https://xyz.trycloudflare.com forwards to localhost:3000
With the tunnel running, register the public URL:
curl -X POST https://live.twintone.ai/api/v1/webhooks \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.app/twintone/webhook",
"events": ["stream.started", "stream.ended", "stream.error"]
}'
Then start a test stream and watch deliveries hit your local server. Your signature-verification code works unchanged behind a tunnel — verify the HMAC over the raw request body exactly as you would in production.
Deliveries also appear in the dashboard under Billing → Webhooks, including failed deliveries and retry status, so you can debug without tailing logs. The dashboard view is read-only — register and manage webhooks through the API.
Was this page helpful?