DocsWebhooks
Webhooks
Get a signed request when a transcription completes or fails, and verify it.
Pass a webhook_url when you create a transcription to receive a signed POST when it completes or fails.
Webhooks are part of the REST API only. The MCP server has no webhook parameter.
Set a webhook URL
Add webhook_url to the JSON body of POST /v1/transcriptions for a YouTube URL or chunked upload reservation. For a one-shot upload, use a form field:
{
"platform": "youtube",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"webhook_url": "https://example.com/hooks/transcript1"
}The URL must use HTTPS, must not contain a user name or password, and must resolve to a public address. A URL that points to a private, loopback, link-local or otherwise non-public address is rejected with 422 invalid_request. The address is checked when you create the transcription and again at every delivery.
webhook_url cannot be combined with reuse_recent=true.
Events
type | Sent when |
|---|---|
transcription.completed | The transcription reached completed. |
transcription.failed | The transcription reached failed. data.error has the code and message. |
A canceled transcription sends no webhook.
Payload
The request body is JSON:
{
"type": "transcription.completed",
"created_at": "2026-10-13T09:30:04.000Z",
"data": {
"id": "tr_8fK2Lm0PqRsTuVwXyZaBcD",
"object": "transcription",
"status": "completed",
"transcript_source": "youtube_captions",
"billed_minutes": 4,
"error": null
}
}created_at is when the event was created. data is the transcription object without the transcript (shortened above). To read the transcript, call GET /v1/transcriptions/:id with the id from data.
The request also carries Content-Type: application/json, a User-Agent of transcript1-webhooks/1.0 and the signature header described next.
Verify the signature
Every delivery carries the header X-Transcript1-Signature:
X-Transcript1-Signature: t=<unix seconds>,v1=<hex digest>tis the Unix time in seconds when the request was signed.v1is the hex-encoded HMAC-SHA256 of the string"<t>.<raw body>": the value oft, a dot, then the request body exactly as received. The key is your full signing secret, including thewhsec_prefix.
Verify the raw body bytes before parsing JSON. Parsing and serializing can change whitespace or key order and invalidate the signature. Compare signatures in constant time and reject requests whose t is too far from the current time. The examples below allow 5 minutes.
Node.js
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';
// header: X-Transcript1-Signature, rawBody: the request body exactly as received,
// secret: your full signing secret including the whsec_ prefix.
export const verifySignature = (secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) => {
const fields = Object.fromEntries(header.split(',').map(part => part.trim().split('=')));
const t = Number(fields.t);
if (!Number.isInteger(t) || !fields.v1 || Math.abs(nowSeconds - t) > 300) {
return false;
}
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return fields.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(fields.v1), Buffer.from(expected));
};With Express, read the body as raw text so it is not parsed first:
import process from 'node:process';
import express from 'express';
import { verifySignature } from './verify-signature.js';
const app = express();
app.post('/hooks/transcript1', express.text({ type: 'application/json' }), (request, response) => {
const header = request.get('X-Transcript1-Signature') ?? '';
if (!verifySignature(process.env.TRANSCRIPT1_WEBHOOK_SECRET, header, request.body)) {
response.sendStatus(400);
return;
}
const { type, data } = JSON.parse(request.body);
console.log(type, data.id, data.status);
response.sendStatus(200);
});Python
import hashlib
import hmac
import time
def verify_signature(secret, header, raw_body, now=None):
# header: the X-Transcript1-Signature header, raw_body: the request body exactly as received,
# secret: your full signing secret including the whsec_ prefix.
fields = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
try:
t = int(fields["t"])
except (KeyError, ValueError):
return False
now = int(time.time()) if now is None else now
if abs(now - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(fields.get("v1", ""), expected)With Flask, pass request.get_data(as_text=True) as raw_body.
Your signing secret
The signing secret starts with whsec_. It belongs to your team, so one secret signs the webhooks of every transcription the team creates. Open the Webhooks page at /dashboard/webhooks to reveal it or rotate it. Rotating replaces the secret immediately; webhooks sent afterwards are signed with the new one.
Delivery rules
- Any
2xxresponse counts as delivered. - Anything else is a failed attempt: another status code, a connection error or no answer within the 10-second timeout.
- A failed delivery is retried, up to 6 attempts in total, with exponential backoff. The waits between attempts are 2, 4, 8, 16 and 32 minutes, so the last attempt happens about an hour after the first.
- transcript1 does not follow redirects. A
3xxresponse counts as a failed attempt, so give it the final URL. - A delivery can arrive more than once, for example if your server answers after the timeout. Use
data.idandtypeto ignore duplicates.
Updated October 14, 2026