Docs · Webhooks

Account Webhooks

Subscribe an https endpoint to job-lifecycle events. SonicVox POSTs a signed JSON envelope when a job finishes or fails — so you never have to poll. Each endpoint gets its own signing secret, and every delivery can be verified before you trust a byte of it.

Subscribing

Two ways to create an endpoint, both handing you the signing secret exactly once:

In the dashboard

Create, edit, test, roll the signing secret, and inspect deliveries in the dashboard: Add-ons → API Webhooks.

Over the API

Account API key (svx_…) with scope `webhooks:read` (GET) or `webhooks:write` (create/update/delete), sent as `x-api-key`.

EndpointWhat it does
GET /api/v1/account/webhooksList your endpoints + the available event types.
POST /api/v1/account/webhooksCreate an endpoint. Body: { url (https), events[] }. Returns `signing_secret` ONCE.
PATCH /api/v1/account/webhooks/{id}Update url and/or events (signing secret preserved).
DELETE /api/v1/account/webhooks/{id}Delete an endpoint and its delivery history.

These power one-click auto-subscribe in the Zapier/n8n connectors. You can also manage endpoints in the dashboard: Add-ons → API Webhooks.

Endpoints must be public https:// URLs (no internal/loopback/metadata addresses).

The signed envelope

Every delivery is this envelope; the per-event fields live under data:

{
  "id": "evt_9zXqL0m2n4pR",
  "type": "generation.completed",
  "version": "2026-07-11",
  "jobId": "cmr9k2a7x0001abcd",
  "data": {
    "clipId": "cmr9k2a7x0001abcd",
    "feature": "Studio Voice",
    "durationSec": 12.4,
    "downloadUrl": "https://<storage-bucket>.s3.<region>.amazonaws.com/…?X-Amz-Expires=3600&… (presigned S3 GET, valid ~1h)"
  },
  "timestamp": "2026-07-11T14:32:07.512Z"
}
idUnique event id, evt_… (same across all endpoints receiving this event).
typeThe event type, e.g. generation.completed.
versionPayload schema version (currently 2026-07-11). Branch on this if it changes.
jobIdThe originating job id (also present inside data); null for events with no job.
dataEvent-specific payload — see the event catalog.
timestampISO-8601 send time.

Delivery headers

HeaderValue
X-SonicVox-Signature-V2t=<unix-seconds>,v1=<hex hmac> — RECOMMENDED, replay-protected (verify this).
X-SonicVox-Signaturesha256=<hex hmac of the raw body> — legacy body-only signature (back-compat).
X-SonicVox-TimestampThe unix-second send time (same `t` as in the V2 signature).
X-SonicVox-Webhook-IdThe id of the endpoint this delivery was sent to.
X-SonicVox-AttemptThe 1-based delivery attempt number.
Idempotency-KeyStable per logical event across retries — dedupe on this.
Content-Typeapplication/json

Verifying the signature

Verify the X-SonicVox-Signature-V2 header (t=<unix-seconds>,v1=<hex>) on every delivery. The scheme is HMAC-SHA256 over `${t}.${rawBody}` (the timestamp, a literal dot, then the exact raw request body), with a replay window of 300 seconds either side of your clock.

The recipe
  1. Parse the header into `t` and `v1`.
  2. Reject if abs(now_seconds - t) > 300 (replay window).
  3. Compute HMAC-SHA256(secret, `${t}.${rawBody}`) as lowercase hex.
  4. Compare to `v1` with a constant-time equality check.
  5. Verify against the RAW request body — do not re-serialize the parsed JSON.
Node.js
import crypto from "node:crypto";

// Express: app.post("/webhook", express.raw({ type: "application/json" }), handler)
function verifySonicVoxWebhook(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // replay window
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// const ok = verifySonicVoxWebhook(req.body, req.get("X-SonicVox-Signature-V2"), process.env.WHSEC);
Python
import hmac, hashlib, time

def verify_sonicvox_webhook(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t = int(parts.get("t", "0"))
    if not t or abs(time.time() - t) > 300:  # replay window
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

# ok = verify_sonicvox_webhook(request.get_data(), request.headers["X-SonicVox-Signature-V2"], WHSEC)

A legacy body-only signature (X-SonicVox-Signature) is still sent for back-compat, but it carries no replay protection — new integrations should verify the V2 header above.

Delivery & retries

Guarantee

Delivery is at-least-once. The same logical event may arrive more than once (retries, or a poll+backstop race). Dedupe on the `Idempotency-Key` header — it is stable across all attempts of one event.

No cross-event ordering guarantee. Do not assume a `.completed` arrives before a later job's event; use the payload ids.

Acknowledging

Respond 2xx within 20 seconds. Any non-2xx, a timeout (>20s), or a connection error counts as a failed attempt.

Retry schedule
immediate → 30s → 2m → 10m → 1h → 4h → dead

Up to 5 retries after the first attempt (waits: 30s, 2m, 10m, 1h, 4h), then the delivery is marked `dead`. Redeliver dead attempts from the dashboard.

Auto-disable

An endpoint is auto-paused after 20 consecutive dead deliveries; the failure counter resets to 0 on any success. Re-enable it in the dashboard once the endpoint is healthy.

Download links

Any `downloadUrl` in a payload is a presigned link valid for ~1 hour. Fetch it promptly, or re-fetch the resource via its API endpoint. Redelivered events may contain an expired URL.

Event catalog

Event typeFires whenjobId refers to
generation.completedA text-to-speech / SFX / voice-changer clip finished and is downloadable.the GeneratedAudioClip id (also on data.clipId)
generation.failedA generation job failed after retries (credits are auto-refunded).the GeneratedAudioClip id
stt.completedA speech-to-text transcription finished.the GeneratedAudioClip id
stt.failedA transcription job failed (credits are auto-refunded).the GeneratedAudioClip id
export.completedA Studio project export (audio or video) finished.the Studio project id
export.failedA Studio project export failed.the Studio project id
trigger.completedAn inbound trigger POST (API Webhooks add-on) synthesized speech.the GeneratedAudioClip id
trigger.failedAn inbound trigger POST could not be synthesized.null (no clip was created)
dubbing.completedA video dubbing job finished. Fetch the output via GET /v1/dubbing/jobs/{id}.the dubbing job id (pass to GET /v1/dubbing/jobs/{id})
dubbing.failedA video dubbing job failed (credits are auto-refunded).the dubbing job id
subtitle.completedA subtitle-creation job finished.the subtitle job id (pass to GET /v1/dubbing/jobs/{id})
subtitle.failedA subtitle-creation job failed (credits are auto-refunded).the subtitle job id
voice_enhancement.completedAn async voice-enhancement (isolation) job finished.the GeneratedAudioClip id
voice_enhancement.failedAn async voice-enhancement job failed (credits are auto-refunded).the GeneratedAudioClip id
workflow_run.completedA Workflows run finished; fetch outputs from GET /v1/workflows/{id}/runs/{runId}.the WorkflowRun id
workflow_run.failedA Workflows run failed; unfinished nodes' reserved credits are settled/refunded by the executor.the WorkflowRun id

Sandbox keys

A sandbox key can list your endpoints (read-only routes run normally), but creating, updating or deleting an endpoint returns sandbox_unsupported_route — webhook subscriptions are real resources with no sandbox behaviour, so mint a live key to manage them. Sandbox generations never reach an engine or create a job, so they never trigger a delivery either; use the dashboard's test delivery to exercise your receiver.