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

  1. Open Webhooks in the console and click "Add endpoint".
  2. Enter a publicly reachable https:// URL and choose the events to subscribe to.
  3. Copy the signing secret (starts with whsec_, shown only once) into your service's configuration.
  4. 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

EventWhendata
synthesis.completedA task finished and the audio is readyTask object
synthesis.failedA task failed (credits are refunded automatically)Task object with error and refund
batch.completedEvery item in a batch has finishedBatch object
webhook.testYou 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
  }
}
  • id is the event ID. It stays the same across retries of the same event - use it to deduplicate.
  • VoiceAILabs-Delivery identifies 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:

  1. Read t and v1 from the header.
  2. Check that t is within 5 minutes of the current time to prevent replay attacks.
  3. Compute the signature over the raw request body (the exact bytes, not re-serialized JSON) and compare it with v1 in 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 "", 204

Responses and retries

  • Return any 2xx status 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 https URLs; 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.