SvelteKit +server.js endpoint to verify a Sume webhook signature
A SvelteKit +server.js POST handler gets a Fetch Request, so request.text() gives the raw body Sume signs. Verify the HMAC, then handle job.completed.

Sume's job webhooks are signed over the exact bytes it sent, so a receiver must read the raw body before any JSON parsing. SvelteKit makes that easy. The SvelteKit routing docs say a +server.js file exports handlers such as POST that take { request }, and that request is a Fetch API Request, so await request.text() returns the raw string, read 2026-10-03.
What Sume sends
Per the Sume webhooks guide, job webhooks are terminal-only: job.completed, job.failed and job.canceled, delivered to a public HTTPS webhook_url. Headers are x-sume-webhook-timestamp, x-sume-webhook-signature (sume-v1=<hex>) and x-sume-webhook-secret-fingerprint. The signature is an HMAC SHA-256 of {timestamp}.{raw_body} with a five-minute tolerance. During secret rotation the signature header carries comma-separated sume-v1= entries, and any match is valid.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(raw, ts, header, secret) {
if (!secret || !header) return false;
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (!(age <= 300)) return false;
const want = createHmac("sha256", secret)
.update(`${ts}.${raw}`).digest("hex");
return header.split(",").some((part) => {
const [k, v] = part.trim().split("=");
return k === "sume-v1" && v?.length === want.length &&
timingSafeEqual(Buffer.from(v), Buffer.from(want));
});
}The route
Put the verifier in src/lib/verify.js and import it in src/routes/hooks/sume/+server.js. Read the body first, verify, and only then parse. An empty secret returns false, so a missing environment variable fails closed instead of accepting everything.
import { verify } from "$lib/verify.js";
export async function POST({ request }) {
const raw = await request.text();
const ok = verify(
raw,
request.headers.get("x-sume-webhook-timestamp"),
request.headers.get("x-sume-webhook-signature"),
process.env.SUME_WEBHOOK_SECRET
);
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(raw);
// enqueue work keyed by the job id, then acknowledge
return new Response("ok");
}A routing trap to avoid
The routing docs note that when a +page file exists in the same directory, a GET, POST or HEAD request whose accept header prefers text/html is treated as a page request. Keep the webhook route in its own directory with no +page. Webhook senders do not ask for HTML, but a browser test might.
Operational rules
- Return a 2xx quickly and do the work after; deliveries are terminal events, so a slow handler only delays your own acknowledgement.
- Dedupe on the job id, since a delivery can be retried.
- Fetch the result from
/v1/jobs/:id/resultrather than trusting the payload alone. - Get the secret from
GET /v1/webhooks/signing-secret(scopeaccount:read) or the dashboard Webhooks tab. - Where
process.envis not available on your adapter, read the secret with that platform's mechanism.
Sources
Related posts
More in Developers
- Swap the TTS engine, keep the voice: Sonic 3.5 to 3.6 on Sume
Cartesia treats the TTS model and the voice as separate things. On Sume the model id and voice id are separate fields, so you can A/B 3.5 and 3.6 on one voice.
- Swift 6.4 CryptoKit: verify a Sume webhook signature
Verify x-sume-webhook-signature in Swift with CryptoKit HMAC<SHA256>: timestamp window, comma-separated entries, constant-time compare, empty secret refused.
- Swift 6.4 URLSession: poll a Sume job with async/await
Swift 6.4 shipped on 15 September 2026. An async URLSession loop for GET /v1/jobs/:id/status that honors next_poll_after_seconds and a 20-minute deadline.
- SWR refreshInterval as a function: poll a Sume job and stop
SWR accepts a function for refreshInterval that receives the latest data. Return Sume's next_poll_after_seconds while running and 0 once terminal is true.
Written by Sume