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.

5 min readSume
All posts

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/result rather than trusting the payload alone.
  • Get the secret from GET /v1/webhooks/signing-secret (scope account:read) or the dashboard Webhooks tab.
  • Where process.env is not available on your adapter, read the secret with that platform's mechanism.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume