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
- 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.
- SWR refreshWhenHidden is false: a hidden tab stops polling Sume jobs
SWR stops polling in a hidden tab by default, but a Sume job keeps running and billing. Store the job id and resume the poll on focus instead of resubmitting.
Written by Sume