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`.
| Endpoint | What it does |
|---|---|
GET /api/v1/account/webhooks | List your endpoints + the available event types. |
POST /api/v1/account/webhooks | Create 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
| Header | Value |
|---|---|
X-SonicVox-Signature-V2 | t=<unix-seconds>,v1=<hex hmac> — RECOMMENDED, replay-protected (verify this). |
X-SonicVox-Signature | sha256=<hex hmac of the raw body> — legacy body-only signature (back-compat). |
X-SonicVox-Timestamp | The unix-second send time (same `t` as in the V2 signature). |
X-SonicVox-Webhook-Id | The id of the endpoint this delivery was sent to. |
X-SonicVox-Attempt | The 1-based delivery attempt number. |
Idempotency-Key | Stable per logical event across retries — dedupe on this. |
Content-Type | application/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.
- Parse the header into `t` and `v1`.
- Reject if abs(now_seconds - t) > 300 (replay window).
- Compute HMAC-SHA256(secret, `${t}.${rawBody}`) as lowercase hex.
- Compare to `v1` with a constant-time equality check.
- Verify against the RAW request body — do not re-serialize the parsed JSON.
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);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
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.
Respond 2xx within 20 seconds. Any non-2xx, a timeout (>20s), or a connection error counts as a failed attempt.
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.
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.
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 type | Fires when | jobId refers to |
|---|---|---|
generation.completed | A text-to-speech / SFX / voice-changer clip finished and is downloadable. | the GeneratedAudioClip id (also on data.clipId) |
generation.failed | A generation job failed after retries (credits are auto-refunded). | the GeneratedAudioClip id |
stt.completed | A speech-to-text transcription finished. | the GeneratedAudioClip id |
stt.failed | A transcription job failed (credits are auto-refunded). | the GeneratedAudioClip id |
export.completed | A Studio project export (audio or video) finished. | the Studio project id |
export.failed | A Studio project export failed. | the Studio project id |
trigger.completed | An inbound trigger POST (API Webhooks add-on) synthesized speech. | the GeneratedAudioClip id |
trigger.failed | An inbound trigger POST could not be synthesized. | null (no clip was created) |
dubbing.completed | A 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.failed | A video dubbing job failed (credits are auto-refunded). | the dubbing job id |
subtitle.completed | A subtitle-creation job finished. | the subtitle job id (pass to GET /v1/dubbing/jobs/{id}) |
subtitle.failed | A subtitle-creation job failed (credits are auto-refunded). | the subtitle job id |
voice_enhancement.completed | An async voice-enhancement (isolation) job finished. | the GeneratedAudioClip id |
voice_enhancement.failed | An async voice-enhancement job failed (credits are auto-refunded). | the GeneratedAudioClip id |
workflow_run.completed | A Workflows run finished; fetch outputs from GET /v1/workflows/{id}/runs/{runId}. | the WorkflowRun id |
workflow_run.failed | A 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.