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.