verifyWebhook toleranceSeconds: 300 by default, and 0 turns replay off

In the Sume SDK, verifyWebhook rejects deliveries older than 300 seconds by default. toleranceSeconds 0 skips that check. When each setting is right.

5 min readSume
All posts

verifyWebhook in @sume-com/sdk rejects a delivery whose x-sume-webhook-timestamp is more than 300 seconds from your clock. That window is the toleranceSeconds default. If you pass 0, the helper skips the timestamp check entirely, and a captured delivery with a valid signature verifies forever. Keep the default in production and use 0 only in a unit test with a fixed fixture.

What the setting does

The signed text is <timestamp>.<raw_body>, so the timestamp is covered by the HMAC. An attacker cannot change it without breaking the signature, but they can resend an old delivery unchanged. The replay window is what turns that resend into a rejection. The SDK enforces the window before it computes the HMAC, compares in constant time, and returns false instead of throwing.

verifyWebhook input and behavior, as of 2026-10-08
Option or ruleValueEffect
toleranceSeconds300 (default)Reject when the timestamp is more than 300 s from now
toleranceSeconds0Skip the timestamp check; replay is possible
bodyraw string, ArrayBuffer or typed arrayParsed and re-serialized JSON will not verify
Return valuetrue or falseMalformed header, bad timestamp and bad signature all return false
RotationSeveral sume-v1 entries acceptedAny matching entry passes

Why not just widen the window

A wide window hides the real problem. If valid deliveries fail the check, the usual cause is a skewed server clock, or a proxy that holds requests before forwarding them. Fix the clock first. The docs call five minutes a reasonable default for the replay window, and the SDK default matches it.

Two more details matter. First, the replay check does not replace deduplication. A delivery inside the window can still arrive twice, for example when an earlier attempt timed out, so store request_id or job_id and ignore repeats. Second, an empty secret must be a startup error. A verifier that quietly accepts an empty string as a secret turns signature checking into a formality.

A route that fails closed

The handler below reads the raw body first, refuses to start without a secret, keeps the default window, and answers 401 on any failure.

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

const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (secret.length === 0) {
  throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
}

export async function POST(request: Request) {
  const body = await request.text(); // raw, before JSON.parse
  const ok = await verifyWebhook({
    body,
    headers: request.headers,
    secret,
    toleranceSeconds: 300, // the default, written out
  });
  if (!ok) return new Response("bad signature", { status: 401 });

  const event = JSON.parse(body);
  console.log("verified", event.event, event.job_id);
  return new Response(null, { status: 204 });
}

When 0 is acceptable

Use 0 in a test that replays a stored fixture whose timestamp is long past. Do not read it from an environment variable that production could set by accident. A better habit is to inject a fixed clock in tests so the production code path stays the same.

A test plan for the window

Four fixtures cover the window. A fresh delivery verifies. A delivery signed correctly but stamped 301 seconds ago returns false. A delivery with a missing timestamp header returns false, not an exception. And a delivery with a valid timestamp but a body edited by one byte returns false. If you test against a fixed clock, also check that a delivery stamped 299 seconds ago still passes, so you know the boundary is where you think it is.

Run the same four cases through any hand-rolled verifier in another language. The raw scheme is HMAC SHA-256 over the timestamp, a dot and the raw body, compared against each sume-v1= entry, which is easy to port. The part people forget is the window, because the signature alone will pass for an old but untouched delivery.

Job webhooks and run webhooks use the same signature scheme and the same signing secret, so this one verifier and one tolerance cover both. Route on the event field after verification. Do not expect a run_id in a job body or a job_id in a run body.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume