Webhook timestamp tolerance: Sume 300 s versus Stripe 5 min

Sume's verifyWebhook rejects deliveries older than 300 seconds by default. Why clock skew matters, why toleranceSeconds 0 is a trap, and how to fix drift.

5 min readSume
All posts

Short answer

verifyWebhook in @sume-com/sdk checks the x-sume-webhook-timestamp header and rejects a delivery outside a 300 second window by default. Stripe's libraries use the same 5 minute default, and its docs say to keep your server clock accurate with NTP and never to use a tolerance of 0, because that disables the recency check. Sume's option works the same way: toleranceSeconds 0 skips the timestamp check.

Why a window exists

The signature is an HMAC-SHA256 over the timestamp, a dot and the raw body. Anyone who captures a valid delivery can replay it later, and the signature will still match. The timestamp window is what limits that: a captured request is only accepted for a few minutes.

Timestamp tolerance, Sume versus Stripe (read 2026-10-03)
ItemSume SDKStripe libraries
Default tolerance300 seconds5 minutes
SettingtoleranceSecondsLibrary tolerance parameter
Tolerance 0Skips the timestamp checkStripe says do not use it; it disables the check
Clock adviceKeep receivers on accurate timeUse NTP

Clock skew in practice

If your receiver's clock runs more than five minutes ahead or behind, every genuine delivery fails verification and the symptom looks like a bad secret. Check this before you rotate anything. Compare the x-sume-webhook-timestamp value with your server time on a failing request, and compare the secret fingerprint header, which is safe to paste into a ticket, with the one on the dashboard.

Containers and CI runners are the usual culprits, especially long-lived dev machines that were suspended. Run a time sync service on the host and fail your health check if drift is large.

Do not widen the window to fix a bug

Raising toleranceSeconds to an hour hides drift and widens the replay window. Setting it to 0 removes the protection entirely. The better fix is accurate time on the receiver. If you must accept slow queues, verify at the edge, in the handler that receives the request, and only then hand the verified payload to a queue. Do not verify later in a worker, where the timestamp is already old and a valid delivery would look stale.

Replay protection by timestamp is not deduplication. Sume can deliver more than once, so dedupe on the request_id in the envelope and order by created_at. See dedupe on request_id.

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

export async function POST(request: Request) {
  const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
  if (!secret) return new Response("not configured", { status: 500 });
  const body = await request.text();
  const ok = await verifyWebhook({
    body,
    headers: request.headers,
    secret,
    toleranceSeconds: 300,
  });
  return new Response(null, { status: ok ? 204 : 401 });
}

Rotation and the window

During a secret rotation, Sume signs with both secrets for 24 hours and sends two comma-separated signatures. Tolerance is unrelated to that window, so a rotation never needs a wider tolerance. Upgrade the receiver before you rotate, so that it accepts either signature.

A debugging order that saves time

When verification starts failing in production, work through the causes from cheapest to most disruptive. First compare the fingerprint header with the one on the dashboard; a mismatch means the receiver holds the wrong secret. Second, check the body: a framework that parses JSON before your handler has already changed the bytes, and the signature covers the raw body. Third, compare timestamps with your server clock. Only after those three should you rotate the secret.

Log the timestamp header and your own clock on every failure, never the secret and never the signature. Those two numbers settle the skew question in one line of output.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume