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.

5 min readSume
All posts

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.

Job webhook events for a TTS job (read 2026-10-06)
EventWhenWhat to do
job.completedA public result is availableRead payload.artifacts, store the audio
job.failedThe job failed with a public errorRead error, do not retry blindly
job.canceledThe job reached canceledRelease 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

All Developers posts

Written by Sume