Skip to content

Guides

Beta

Webhooks

Instead of polling, let Speak tell you when an import, transcript or export finishes. Add HTTPS endpoints under Settings → API keys or with POST /webhooks.

Events

TypeWhen
project.importedA source_url import finished downloading. data: project_id, job_id, status.
project.import_failedThe import failed (unreachable link, not a media file…). data.error explains why.
transcript.completedTranscription/alignment finished; GET the transcript now. data: project_id, job_id.
transcript.failedTranscription failed; any credits charged were refunded. data.error explains why.
export.completedAn export rendered; GET /exports/{export_id} for its download_url. data: project_id, export_id.
export.failedAn export failed; credits for it were refunded. data.error explains why.

An endpoint with an empty events list receives every type. Events created before an endpoint existed are not sent to it.

Delivery

Each event is POSTed as JSON with three headers: Speak-Event-Id, Speak-Event-Type and Speak-Signature. Answer with any 2xx within 10 seconds; do slow work after responding.

http
POST /webhooks/speak HTTP/1.1
Content-Type: application/json
Speak-Event-Id: 6c0f2b9e-4d1a-4f3e-9a57-0e8b1c2d3f40
Speak-Event-Type: export.completed
Speak-Signature: t=1791734602,v1=5f2a…c41e

{
  "id": "6c0f2b9e-4d1a-4f3e-9a57-0e8b1c2d3f40",
  "type": "export.completed",
  "created_at": "2026-10-10T14:03:22Z",
  "data": {
    "project_id": "3f0c7a52-8d7e-4a51-9b1e-2f1c0d6e8a41",
    "export_id": "b81e2d4c-77a0-4c0f-a6f3-5d9e1c2b3a70",
    "status": "completed",
    "error": null
  }
}
  • Non-2xx answers, timeouts and connection errors are retried with exponential backoff (1 min, 2, 4… capped at 6 h) for about 24 hours, then the delivery is marked failed. Recent deliveries and their last response are listed in Settings.
  • Redirects are not followed; point the endpoint at its final URL.
  • The same event can arrive more than once and out of order. Deduplicate on the event id and re-read the resource (GET /exports/{id}) for its current state.
  • Endpoints must be public HTTPS on the default port; URLs that resolve to private or internal addresses are refused.
  • Send test event in Settings delivers a transcript.completed event with "test": true and all-zero ids to that endpoint only.

Verifying signatures

Speak-Signature looks like t=1791734602,v1=5f2a…. v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint's signing secret: the whole whsec_… string, prefix included. Compute it over the raw bytes you received (not re-serialised JSON), compare in constant time, and reject timestamps more than five minutes from now. The secret is shown once when the endpoint is created; to rotate, add a second endpoint, switch your receiver over, then delete the old one.

server.mjs (Node.js, Express)js
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.SPEAK_WEBHOOK_SECRET; // "whsec_…", used as-is (prefix included)
const TOLERANCE_SEC = 300;

/** True when the Speak-Signature header matches the raw body. */
function verifySpeakSignature(rawBody, header, secret) {
  if (!header) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=", 2);
    if (key === "t") timestamp = Number(value);
    if (key === "v1" && value) signatures.push(value);
  }
  if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SEC) return false; // stale: possible replay

  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  return signatures.some(
    (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
  );
}

const app = express();
const seen = new Set(); // use your database in production

// express.raw keeps the exact bytes we signed; parse JSON only after verifying.
app.post("/webhooks/speak", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySpeakSignature(req.body, req.get("Speak-Signature"), SECRET)) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204); // acknowledge quickly, then do the work

  if (seen.has(event.id)) return; // deliveries can repeat: be idempotent on event.id
  seen.add(event.id);
  if (event.type === "export.completed") queueDownload(event.data.export_id);
});
app.py (Python, Flask)python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["SPEAK_WEBHOOK_SECRET"]  # "whsec_...", used as-is (prefix included)
TOLERANCE_SEC = 300

def verify_speak_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
    if not header:
        return False
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SEC:
        return False  # stale: possible replay
    signed = str(timestamp).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(sig, expected) for sig in signatures)

app = Flask(__name__)

@app.post("/webhooks/speak")
def speak_webhook():
    raw = request.get_data()  # the exact bytes, before any JSON parsing
    if not verify_speak_signature(raw, request.headers.get("Speak-Signature"), SECRET):
        abort(400)
    event = json.loads(raw)
    handle_once(event["id"], event)  # deliveries can repeat: dedupe on the event id
    return "", 204

Using the SDKs? await Speak.webhooks.verify(rawBody, header, secret) (TypeScript) does the same checks and returns the parsed event.