Express receiver for Sume TTS job webhooks: raw body and HMAC
A Node Express route that verifies Sume job webhooks over the raw body, accepts rotated secrets, rejects an empty secret and answers 204 before any work.

To receive a Sume TTS webhook in Express, mount express.raw() on the route, compute HMAC-SHA256 of <timestamp>.<raw body> with your signing secret, compare it in constant time against the sume-v1= value in x-sume-webhook-signature, reject timestamps more than five minutes old, and return 204 before you do any work. Refuse to boot if the secret is empty: an empty HMAC key still produces a valid-looking digest, so a missing variable would silently accept forged requests.
Microsoft's new MAI-Voice-2.1 family is priced per million characters and, for the Flash variant, sold on a vendor-claimed latency (read 2026-10-06 on the October tracker). Sume's TTS Router is a job API instead, and a webhook is how a finished voiceover reaches your server without a poll loop.
The receiver
const express = require("express");
const crypto = require("crypto");
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET || "";
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
function verify(raw, ts, header) {
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${ts}.`).update(raw).digest();
return header.split(",").some((part) => {
const hex = part.trim().replace(/^sume-v1=/, "");
const got = Buffer.from(hex, "hex");
return got.length === want.length && crypto.timingSafeEqual(got, want);
});
}
const seen = new Set();
const app = express();
app.post("/sume/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verify(req.body, req.get("x-sume-webhook-timestamp") || "",
req.get("x-sume-webhook-signature") || "");
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
if (event.event === "job.completed" && !seen.has(event.job_id)) {
seen.add(event.job_id);
console.log("voiceover ready", event.job_id, event.payload.artifacts[0].url);
}
res.sendStatus(204);
});
app.listen(3000, () => console.log("listening on 3000"));What the code follows
Four details in that file come from the Webhooks page and the run-webhooks page that shares its signature scheme. First, the signature covers the raw bytes, so a framework that parses JSON first has already destroyed the signed input; that is why only this route uses express.raw. Second, during a secret rotation the header carries one sume-v1= entry per live secret, newest first, separated by commas, and any match is valid. Third, the docs tell you to use job_id as your idempotency key, so dedupe on it. Fourth, Sume does not follow redirects, so register the final HTTPS URL.
The Set above is a stand-in. Use a database insert-or-ignore on job_id in production, because a restart empties memory and the next retry would be handled twice.
| Event | When | What to do |
|---|---|---|
| job.completed | A public result is available | Read payload.artifacts, store the audio |
| job.failed | The job failed with a public error | Read error, do not retry blindly |
| job.canceled | The job reached canceled | Release whatever your side reserved |
Switching it on
Webhooks are terminal-only: there are no progress or partial events. A delivery has 10 seconds to return 2xx and up to 10 attempts. If your receiver downloads and transcodes audio before it answers, Sume retries while you work. Record the event, answer, then do the work.
To turn it on, pass webhook_url on the TTS submit, or use mode: "webhook". Read the signing secret from the dashboard Webhooks tab or GET /v1/webhooks/signing-secret. The same secret signs Format run webhooks, so one verifier covers both; route on event and send 204 for names you do not know, so a new event type never causes a retry storm.
TTS pricing is on the API pricing page: $0.0475 per 1,000 characters (read 2026-10-06). The webhook itself costs nothing extra.
To try the receiver before you deploy it, expose port 3000 through any HTTPS tunnel, since Sume rejects plain HTTP, localhost and private ranges at submit and checks the URL again at delivery. Submit a short TTS job with webhook_url set to the tunnel address plus /sume/webhook, and watch the console. If every delivery gets a 401, compare x-sume-webhook-secret-fingerprint with the fingerprint shown next to the secret in the dashboard: a mismatch means the two sides hold different secrets, and the fix is the environment variable, not the code.
Sources
Related posts
More in Developers
- Fall back to a second image model after three 502s: a Python breaker
Sume returns 502 when an image job fails inside the wait budget. Count them, switch to a second model id after three, and use a new idempotency key per model.
- Gemini CLI and the hosted Sume server: do not rely on env in headers
Add the hosted Sume server to Gemini CLI with httpUrl and a bearer header. Gemini expands env vars only in the env block; set a timeout above jobs_wait.
- Get the video URL from a Sume webhook: pick the artifact by type
A Sume job.completed payload lists artifacts with id, url, type and content_type. Select the video by content_type, not array index. TypeScript for Node.
- Go net/http client for Sume images: handle 200 and 202 on a model swap
A Go program that posts to Sume /v1/images with the model id from an env var and branches on 200 versus 202, so a gpt-image-1 swap is not a rebuild.
Written by Sume