TwinToneAPI Docs

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 secret is returned once, at registration. Store it with the same care as an API key. GET /webhooks never returns it again.

Events

EventFires when
stream.startedStatus transitions preparing → live.
stream.endedStatus transitions live → ended.
stream.errorStream 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 2xx within 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

MethodPathNotes
GET/api/v1/webhooksList (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?