Fastify 5 raw body for a Sume webhook: parseAs string

Fastify parses JSON before your handler, which breaks HMAC checks. Use parseAs string, then verify sume-v1 and dedupe. Tested on Fastify 5.12.5.

4 min readSume
All posts

In Fastify, a Sume webhook route fails signature checks by default because the JSON content-type parser turns the body into an object before your handler runs, and the HMAC covers the original bytes. Register your own parser for application/json with { parseAs: "string" } and the handler receives the raw text, which you verify and only then JSON.parse. The 27-line app below does that on Fastify 5.12.5.

Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>. A re-serialized object does not match, since key order and whitespace were part of what was signed.

Facts

Receiver requirements, read 2026-10-02 from docs.sume.com
RequirementValue
Raw bodyVerify before parsing
Replay window300 seconds suggested
RotationHeader may carry two sume-v1 entries for 24 hours
ResponseAny 2xx after storing; 10 attempts, 30 s apart, 10 s timeout
Dedupe keyjob_id, or run_id on run webhooks

The app

The verifier is the shared WebCrypto one from verifyWebhook returns false during a Sume secret rotation, saved as verify.mjs. Fastify gives header values as strings or arrays, so the code keeps only strings; a duplicated signature header is then absent, and verification fails closed.

import Fastify from "fastify";
import { verifySume } from "./verify.mjs";

const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is not set");

const app = Fastify({ logger: false });
// Keep the bytes: Fastify's default JSON parser would hand you an object.
app.addContentTypeParser("application/json", { parseAs: "string" }, (req, body, done) => done(null, body));

const seen = new Set(); // use a database unique index in production

app.post("/webhooks/sume", async (req, reply) => {
  const body = req.body; // raw string
  const headers = new Headers(
    Object.entries(req.headers).filter(([, v]) => typeof v === "string"));
  if (!(await verifySume({ body, headers, secret }))) return reply.code(401).send("bad signature");
  const event = JSON.parse(body);
  const id = event.job_id ?? event.run_id;
  if (id && !seen.has(id)) {
    seen.add(id);
    setImmediate(() => console.log("handle", event.event, id)); // work after the 2xx
  }
  return reply.code(204).send();
});

await app.listen({ port: Number(process.env.PORT ?? 8789) });

Where it goes wrong

Three practical traps cost the most time in this setup:

  • Registering the parser on the whole app also changes every other JSON route. Scope it with app.register and an encapsulated plugin if you have other JSON endpoints.
  • The default body limit is 1 MiB, which is plenty for a terminal job event; do not raise it to make a failing signature go away.
  • A proxy that rewrites the body (compression, charset changes) breaks the signature. Compare x-sume-webhook-secret-fingerprint with the dashboard value first, then suspect the proxy.

Limits

On Fastify 5.12.5 and Node 22.14, signed local requests returned 204 for a first delivery, 204 for a repeat with no second handling, and 401 for a corrupted signature. These were not deliveries from Sume. The in-memory Set loses state on restart. The route logs and returns; real work belongs in a queue, and the 204 only means you took the event, so keep status polling as the backup for deliveries that never arrive.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume