Webhooks
Get a signed request when a transcription finishes, instead of polling.
Register an endpoint
Create an endpoint in the console (Webhooks → Add endpoint) or with the API. The URL must be HTTPS and publicly reachable. The response includes the signing secret — shown once.
curl "$TWENTYSIX_BASE_URL/v1/webhook_endpoints" \
-H "Authorization: Bearer $TWENTYSIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/transcripts"}'{ "id": "wh_…", "object": "webhook_endpoint", "url": "https://example.com/hooks/transcripts", "secret": "whsec_…" }Then name the endpoint in each transcription that should notify it:
{ "audio": { "upload_id": "upl_…" }, "language": "bn-IN", "webhook_endpoint_id": "wh_…" }Events
When the transcription finishes we send one POST with a small JSON body. It never contains the transcript — fetch that with your API key.
{ "type": "transcription.completed", "data": { "id": "tr_…", "state": "completed" } }| Event | When |
|---|---|
transcription.completed | The full transcript is ready. |
transcription.partial | A partial transcript is ready (see partial results). |
transcription.failed | It could not be transcribed; data.error.code says why. |
transcription.cancelled | It was cancelled. |
Reply with any 2xx status within ten seconds. Other replies, timeouts and redirects count as failures and are retried with increasing delays for about a day. Every attempt is listed in the console under Webhooks. A transcription's state never depends on your endpoint: if deliveries fail, the result is still there to fetch.
Verify the signature
Every request carries three headers:
| Header | Value |
|---|---|
webhook-id | Unique per event — use it to ignore duplicates. |
webhook-timestamp | Unix seconds when it was sent. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your secret after whsec_. |
Check the signature against the raw body before parsing it, and reject timestamps more than five minutes old.
from twentysix import verify_webhook
@app.post("/hooks/transcripts")
def hook(request):
if not verify_webhook(SECRET, request.headers, request.body):
return Response(status=400)
event = json.loads(request.body)
if event["type"] in ("transcription.completed", "transcription.partial"):
result = client.result(event["data"]["id"])
return Response(status=204)import { verifyWebhook } from "@26ai/sdk";
export async function POST(req: Request) {
const body = await req.text();
if (!(await verifyWebhook(process.env.WEBHOOK_SECRET!, req.headers, body))) return new Response(null, { status: 400 });
const event = JSON.parse(body);
// fetch the transcript with client.result(event.data.id)
return new Response(null, { status: 204 });
}import base64, hashlib, hmac, time
def verify(secret, headers, body: bytes) -> bool:
msg_id, ts, sig = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(mac).decode()
return any(hmac.compare_digest(expected, s) for s in sig.split())Delivery behaviour
- Events can arrive more than once; deduplicate on
webhook-id. - Events are not guaranteed to arrive in order. Fetch the transcription if you need its latest state.
- Endpoints that resolve to private or internal addresses are refused, at registration and on every delivery.