Notion X-Notion-Signature vs Sume sume-v1: two verifiers

Notion signs the body with your verification token. Sume signs a timestamp plus the body. Here is how to keep the two checks apart in one receiver.

5 min readSume
All posts

If one endpoint receives both Notion events and Sume run events, give each its own verifier. Notion signs the request body with HMAC-SHA256 keyed by the verification_token and sends it in X-Notion-Signature. Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends x-sume-webhook-signature: sume-v1=<hex>. The two use different secrets, different inputs and different headers.

The Notion facts are from Webhooks, read 2026-10-10. The Sume facts are from Verifying webhooks and Run webhooks.

How do they differ?

Notion starts with a handshake: when you create a subscription, it sends a verification_token to your URL, and you use it to confirm and later to compute the HMAC. The body alone is signed. Sume signs the timestamp from x-sume-webhook-timestamp together with the raw body, which gives you a replay window; the SDK default is 300 seconds. Both require the public HTTPS URL.

Notion's page also notes that webhook URLs must be public HTTPS, like Sume's. If you test locally, use a tunnel for both. For Sume, remember that private and localhost hosts and explicit ports are refused, so a tunnel URL on the standard port is the right shape.

Two signatures (Notion page read 2026-10-10; Sume docs)
PointNotionSume
HeaderX-Notion-Signaturex-sume-webhook-signature, value sume-v1=<hex>
Signed inputThe request body<timestamp>.<raw_body>
KeyThe verification_token from the handshakeThe signing secret from the dashboard or GET /v1/webhooks/signing-secret
Replay guardNot part of the signatureTimestamp header, 300 second default window
RotationNot covered hereHeader carries sume-v1=new,sume-v1=old for 24 hours

How should the receiver route them?

Branch on the header before touching the body. If x-sume-webhook-signature is present, use the Sume verifier; if X-Notion-Signature is present, use Notion's. A request with neither is rejected. Never try one secret against the other's header, and never share a variable between them.

Read the raw bytes once. Parsing the JSON and re-serializing it changes the bytes and breaks both signatures.

Test each verifier with a known-bad case. Flip one byte of the body, change the timestamp by 10 minutes, and send an empty secret. All three should return false. The empty-secret case matters because a missing environment variable otherwise turns into an HMAC over an empty key, which anyone can compute.

import crypto from "node:crypto";

export function verifySume(raw, headers, secret) {
  if (!secret) return false;
  const ts = headers["x-sume-webhook-timestamp"];
  const sigs = String(headers["x-sume-webhook-signature"] || "").split(",");
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const want = crypto.createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex");
  return sigs.some(s => {
    const got = s.trim().replace(/^sume-v1=/, "");
    return got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
  });
}

What about timing differences?

Notion events are not all immediate. The page says page.content_updated is aggregated, with up to about a minute of delay, while comment.created arrives within seconds. Do not start a paid Sume run on every content update; wait for the aggregated event, then check whether the page actually changed in a way that needs a new render.

On the Sume side, a run webhook is one event per run, with request_id equal to the run id. Dedupe on it. For Notion, store the event id the payload carries.

Do not start a paid run from a bare Notion event. Verify it, check which property changed, and send the Sume request with an Idempotency-Key made from the page id and the page's last edited time, so the aggregated events cannot make two renders.

What about secret rotation?

When you rotate Sume's signing secret, deliveries carry both signatures for 24 hours, sume-v1=new,sume-v1=old, which is why the code above accepts any matching entry. Update your stored secret within that window and the cut-over is quiet. Notion's token is separate; keep the two in different environment variables and never log either.

Where you can, prefer the SDK helper verifyWebhook from @sume-com/sdk. It is async, returns false instead of throwing, and checks the replay window before computing the HMAC.

Finally, log which verifier accepted each request, but never log the secrets or the full signature header. A short label is enough to debug a mixed endpoint.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume