Guides
BetaWebhooks
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
| Type | When |
|---|---|
project.imported | A source_url import finished downloading. data: project_id, job_id, status. |
project.import_failed | The import failed (unreachable link, not a media file…). data.error explains why. |
transcript.completed | Transcription/alignment finished; GET the transcript now. data: project_id, job_id. |
transcript.failed | Transcription failed; any credits charged were refunded. data.error explains why. |
export.completed | An export rendered; GET /exports/{export_id} for its download_url. data: project_id, export_id. |
export.failed | An 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.
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
idand 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.completedevent with"test": trueand 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.
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);
});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 "", 204Using the SDKs? await Speak.webhooks.verify(rawBody, header, secret) (TypeScript) does the same checks and returns the parsed event.