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-Signature—sha256=<hex>, the HMAC-SHA256 of<timestamp>.<raw-body>keyed with yourwhsec_…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.