Nuxt server route for Sume webhooks: readRawBody, then verify

A Nuxt 3 or 4 server route at server/api/sume.post.ts that reads the raw body with readRawBody, checks sume-v1 with node:crypto, and answers 204 fast.

5 min readSume
All posts

In Nuxt, put the receiver in server/api/sume.post.ts, read the body with Nitro's readRawBody(event, "utf8") before anything parses it, verify the sume-v1 signature, and answer 204 quickly. The signature covers the exact bytes Sume sent, so a handler that reads readBody(event) first has already turned them into an object and can no longer verify.

Sume signs the raw JSON body with HMAC-SHA256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp plus x-sume-webhook-signature: sume-v1=<hex>; during a secret rotation the header carries one sume-v1= entry per live secret, comma-separated, newest first. The secret is on the Webhooks tab of the dashboard or from GET /v1/webhooks/signing-secret; Sume's own samples call it SUME_COM_WEBHOOK_SIGNING_SECRET.

What does the route look like?

Nitro auto-imports the h3 helpers, so the file needs only node:crypto. Put the secret in runtimeConfig so Nuxt reads it from NUXT_SUME_WEBHOOK_SECRET at runtime.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(raw: string, ts: string, header: string, secret: string) {
  if (!secret) return false; // refuse an empty secret
  const t = Number(ts);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const want = Buffer.from(
    "sume-v1=" + createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex"),
  );
  return header.split(",").some((e) => {
    const got = Buffer.from(e.trim());
    return got.length === want.length && timingSafeEqual(got, want);
  });
}

export default defineEventHandler(async (event) => {
  const raw = (await readRawBody(event, "utf8")) ?? "";
  const ok = verify(
    raw,
    getHeader(event, "x-sume-webhook-timestamp") ?? "",
    getHeader(event, "x-sume-webhook-signature") ?? "",
    useRuntimeConfig().sumeWebhookSecret as string,
  );
  if (!ok) throw createError({ statusCode: 401, statusMessage: "bad signature" });
  const payload = JSON.parse(raw);
  // store payload.request_id first, then do the work elsewhere
  setResponseStatus(event, 204);
  return null;
});

What decides whether it works?

Receiver checklist for a Nuxt server route, Sume docs read 2026-10-04
RuleWhyIn the route
Raw body firstKey order and whitespace are part of what was signedreadRawBody before JSON.parse
Refuse an empty secretAn empty HMAC key verifies nothingif (!secret) return false
Accept any sume-v1 entryRotation sends two for 24 hoursheader.split(",").some(...)
Constant-time compareAvoid leaking a prefix matchtimingSafeEqual on equal-length buffers
Fast 2xx10 s per attempt, 10 attempts total204 after storing the event

What should happen after the 204?

Dedupe on job_id for generation-job events and on request_id (equal to run_id) for run events, because the same value arrives on every retry. Write the event to a table or a queue inside the handler, return, and do the heavy work in a Nitro task or a worker. A slow handler burns the 10-second attempt budget and Sume retries.

Route on event, and send a 204 for names you do not know yet, so a new event type never becomes a 500 and a retry storm.

What do you check last?

  • nuxi dev behind a tunnel works for a first test; confirm the body bytes survive the tunnel unchanged.
  • The dashboard's Send test posts a signed webhook.test dummy; it has no job_id, so do not feed it to your job logic.
  • Keep polling status_url as a backup for deliveries that never arrive.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume