Fastify raw body for a Sume webhook: parseAs string and verifyWebhook

Fastify parses JSON before your handler, which breaks signatures. Use addContentTypeParser with parseAs string and verifyWebhook from @sume-com/sdk.

4 min readSume
All posts

In Fastify, register a JSON content-type parser with parseAs: "string" so the handler receives the exact body text, then pass it to verifyWebhook from @sume-com/sdk and answer 204. Without that parser the body is already parsed and the Sume signature cannot match.

Sume signs each delivery, so the receiver has one job: hand the untouched bytes to the HMAC check, reject anything that fails, store the event, and answer with a 2xx quickly. The rest of this page shows that for Fastify.

Why does a Fastify webhook fail verification?

Fastify's docs show addContentTypeParser with a parseAs option set to 'string' or 'buffer' (the default is 'buffer'), and the body argument then arrives in that form. Returning the string unchanged from the parser keeps the bytes Sume signed. Scope the parser to the webhook route in a plugin if the rest of your app wants parsed JSON.

The signature covers the timestamp, a dot, and the raw body. Sume's docs say a parsed-and-reserialized object does not verify, because key order and whitespace are part of the signed data. So the check must run before any JSON parsing.

What does a Fastify receiver look like?

The sample below reads the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name the Sume docs use. It refuses to run the check when the secret is empty, enforces the 300-second replay window, and accepts the delivery when any sume-v1 entry matches, which keeps it working during a secret rotation.

import Fastify from "fastify";
import { verifyWebhook } from "@sume-com/sdk";

const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
const app = Fastify();

// Keep the body as a string so the signature sees the exact bytes.
app.addContentTypeParser("application/json", { parseAs: "string" },
  (_req, body, done) => done(null, body));

app.post("/hooks/sume", async (req, reply) => {
  if (!secret) return reply.code(500).send("secret not configured");
  const body = req.body as string;
  const ok = await verifyWebhook({ body, headers: req.headers, secret });
  if (!ok) return reply.code(401).send("bad signature");
  const event = JSON.parse(body);
  if (event.event?.startsWith("job.")) {
    await saveOnce(event.job_id, event); // dedupe on job_id
  }
  return reply.code(204).send();
});

async function saveOnce(id: string, event: unknown) {
  console.log("store", id, typeof event); // replace with a durable write
}

await app.listen({ port: 3000 });

What does Sume send and expect back?

Job webhooks carry job.completed, job.failed or job.canceled, and terminal events only: there are no progress deliveries. Run webhooks (Formats, Actions, Agent Completions) use the same signature scheme, so one verifier covers both. Route on the event field and answer unknown events with 204 so a new event type never becomes a retry storm.

What a Fastify receiver must do for each Sume delivery (Sume docs, read 2026-10-11)
StepValue from the Sume docs
Signed data<timestamp>.<raw_body>, HMAC-SHA256 with your workspace secret
Signature headerx-sume-webhook-signature: sume-v1=<hex>; during a rotation, two comma-separated entries, newest first
Timestamp headerx-sume-webhook-timestamp; default replay window 300 seconds
Delivery limitsUp to 10 attempts, 30 s apart by default, 10 s timeout per attempt
Dedupe keyjob_id (job events) or request_id

How do I handle retries and lost deliveries?

Return a 2xx only after the event is stored durably. After ten refused attempts the job still reached its terminal state; only the notification failed. Keep a poll on the job's status_url as a fallback, or ask Sume to re-send the real terminal event with POST /v1/jobs/{job_id}/webhook/redeliver (needs the jobs:write scope). Redelivery carries a fresh timestamp and signature, so your replay window will accept it.

Use the dashboard's Send test (or POST /v1/webhooks/test-deliveries) to check the route first. It posts a dummy signed webhook.test payload that has no job_id, so your handler should treat it as a no-op rather than an error.

  • Verify on raw bytes before parsing.
  • Never run the check with an empty secret.
  • Dedupe on job_id; redelivery is a normal event.
  • If signatures fail, compare x-sume-webhook-secret-fingerprint with the fingerprint shown in the dashboard before you paste anything into a ticket.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume