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

typeSent when
transcription.completedThe transcription reached completed.
transcription.failedThe 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>
  • t is the Unix time in seconds when the request was signed.
  • v1 is the hex-encoded HMAC-SHA256 of the string "<t>.<raw body>": the value of t, a dot, then the request body exactly as received. The key is your full signing secret, including the whsec_ 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 2xx response 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 3xx response 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.id and type to ignore duplicates.

Updated October 14, 2026