Tunova

Suno API webhooks, done right

Music generation takes 1–3 minutes, so you have two ways to find out a job finished: poll GET /api/jobs/{id} on a loop, or pass a callback_url and let the API push you a webhook the moment it’s done. Webhooks are cheaper and lower-latency — but only if you verify them. Here’s how to do it properly.

The signature scheme

Tunova signs every webhook. Two headers come with the POST:

  • X-Webhook-Timestamp — unix seconds when we sent it.
  • X-Webhook-Signaturesha256=<hex>, the HMAC-SHA256 of <timestamp>.<raw-body> keyed with your whsec_… secret (view/rotate it on the dashboard’s API-keys page).

Two rules that trip people up: (1) verify against the raw request body, before any JSON parsing re-serializes it; (2) use a constant-time comparison so you don’t leak timing information.

Verify it — Node

import { createHmac, timingSafeEqual } from "node:crypto"; function verify({ secret, timestamp, body, signature, toleranceSec = 300 }) { if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); const provided = signature.startsWith("sha256=") ? signature.slice(7) : signature; const a = Buffer.from(expected), b = Buffer.from(provided); return a.length === b.length && timingSafeEqual(a, b); } // Express: capture the RAW body so the signature matches byte-for-byte app.post("/hooks/tunova", express.raw({ type: "*/*" }), (req, res) => { const ok = verify({ secret: process.env.TUNOVA_WEBHOOK_SECRET, timestamp: req.header("X-Webhook-Timestamp"), body: req.body.toString("utf8"), signature: req.header("X-Webhook-Signature"), }); if (!ok) return res.sendStatus(401); res.sendStatus(200); // ack fast, then process the job });

Verify it — Python

import hmac, hashlib, time def verify(secret: str, timestamp: str, body: str, signature: str, tolerance=300) -> bool: # reject stale deliveries (replay protection) if abs(time.time() - int(timestamp)) > tolerance: return False expected = hmac.new(secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest() provided = signature[7:] if signature.startswith("sha256=") else signature return hmac.compare_digest(expected, provided) # Flask: verify against the RAW body BEFORE parsing JSON raw = request.get_data(as_text=True) if not verify(WHSEC, request.headers["X-Webhook-Timestamp"], raw, request.headers["X-Webhook-Signature"]): abort(401)

Timestamp tolerance (replay protection)

Reject deliveries whose timestamp is more than a few minutes old (300s above). That stops an attacker from replaying a captured valid webhook later.

Idempotency & retries

We retry delivery on non-2xx, so your handler must be idempotent: key on the job_id and ignore a repeat you’ve already processed. Ack fast (return 200 immediately), then do the slow work — audio download, DB writes — out of band, so a slow handler doesn’t look like a failure and trigger a retry.

Don’t want to write this?

The Tunova SDKs ship the verifier above as verifyWebhook (Node) / Tunova.verify_webhook (Python) — one call. And because Tunova is billed only on success, a status: "failed" webhook means the tokens already refunded themselves; your handler just logs it. See the quickstart to get a key (50 free tokens, no card).

Tunova is an independent service, not affiliated with or endorsed by Suno.