Webhooks
Get notified on your server when tasks finish or fail.
With a webhook configured, we send an HTTPS POST to your endpoint whenever a task created through the API finishes - no polling required.
Set up an endpoint
- Open Webhooks in the console and click "Add endpoint".
- Enter a publicly reachable
https://URL and choose the events to subscribe to. - Copy the signing secret (starts with
whsec_, shown only once) into your service's configuration. - Click "Send test event" to confirm your service receives requests.
Each account can have up to 5 endpoints, each with its own secret. Add separate endpoints for staging and production so they stay independent.
Event types
| Event | When | data |
|---|---|---|
synthesis.completed | A task finished and the audio is ready | Task object |
synthesis.failed | A task failed (credits are refunded automatically) | Task object with error and refund |
batch.completed | Every item in a batch has finished | Batch object |
webhook.test | You clicked "Send test event" in the console | { endpoint_id, message } |
Only tasks created through the API trigger events - actions in the web app don't.
Request format
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: VoiceAILabs-Webhooks/1.0
VoiceAILabs-Event: synthesis.completed
VoiceAILabs-Delivery: 5f0e2c1a-…
VoiceAILabs-Signature: t=1791520000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_3b9f0c…",
"object": "event",
"type": "synthesis.completed",
"created_at": "2026-10-09T02:00:06.000Z",
"data": {
"id": "9a3e6b1c-…",
"object": "synthesis",
"type": "tts",
"status": "completed",
"audio_url": "https://cdn.voiceailabs.com/…/9a3e6b1c.mp3",
"duration_ms": 3240,
"credits_used": 17
}
}
idis the event ID. It stays the same across retries of the same event - use it to deduplicate.VoiceAILabs-Deliveryidentifies this delivery attempt and matches the delivery log in the console.
Verify signatures
Every request carries a VoiceAILabs-Signature header in the form t=<Unix seconds>,v1=<signature>. The signature is a hex HMAC-SHA256 of <t>.<raw request body> using your secret.
Verify it as follows, and respond 400 and discard the request if any step fails:
- Read
tandv1from the header. - Check that
tis within 5 minutes of the current time to prevent replay attacks. - Compute the signature over the raw request body (the exact bytes, not re-serialized JSON) and compare it with
v1in constant time.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.VAL_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Use the raw body - don't parse it with express.json() first
app.post("/webhooks/voiceailabs", express.raw({ type: "application/json" }), async (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("VoiceAILabs-Signature") || "")) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
// Respond with 2xx first, then do slow work asynchronously
res.sendStatus(204);
if (await alreadyHandled(event.id)) return;
if (event.type === "synthesis.completed") {
await saveAudio(event.data.id, event.data.audio_url);
}
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["VAL_WEBHOOK_SECRET"].encode() # whsec_...
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify(raw_body: bytes, header: str) -> bool:
try:
parts = dict(part.split("=", 1) for part in header.split(","))
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/webhooks/voiceailabs")
def handle_webhook():
raw_body = request.get_data() # raw bytes
if not verify(raw_body, request.headers.get("VoiceAILabs-Signature", "")):
abort(400)
event = json.loads(raw_body)
if event["type"] == "synthesis.completed":
save_audio(event["data"]["id"], event["data"]["audio_url"])
return "", 204Responses and retries
- Return any
2xxstatus within 10 seconds and the delivery counts as successful. Move slow work (such as downloading audio) to the background. - Any other status, a timeout, a connection error or a 3xx redirect counts as a failure. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h - 7 attempts in total. After that we stop retrying automatically; you can redeliver manually from the delivery log in the console.
- The same event may arrive more than once (for example if your service processed it but the response timed out). Deduplicate by event
id. - Events are not guaranteed to arrive in order. If you need the latest state, confirm with
GET /syntheses/{id}.
Security tips
- Trust only requests whose signature verifies - don't rely on source IPs.
- If your secret leaks, click "Rotate secret" in the console. The old secret stops working immediately, so update your service at the same time.
- Endpoints must be public
httpsURLs; private and local addresses and redirects are not supported. For local development, expose a temporary https URL with a tool such as ngrok or Cloudflare Tunnel. - Disabled or deleted endpoints no longer receive events, and events that occur while an endpoint is disabled are not sent later.