OpenRouter signs timestamp,body; Sume signs timestamp.body

The two webhook signatures differ by one separator and a header shape. Side-by-side table and a tested Python verifier for both, refusing empty secrets.

4 min readSume
All posts

OpenRouter signs {timestamp},{raw_body} with a comma and sends X-OpenRouter-Signature: t=...,v1=.... Sume signs {timestamp}.{raw_body} with a dot and sends the timestamp in x-sume-webhook-timestamp and the signature in x-sume-webhook-signature: sume-v1=.... The same HMAC-SHA256 primitive sits under both, so a port to Sume fails silently if you keep the comma.

Differences that break a ported verifier

Both vendors say to verify the exact bytes received. The fields below come from OpenRouter's video guide and Sume's Webhooks page.

Webhook signature schemes (vendor docs, read 2026-10-09)
ItemOpenRouterSume
Signature headerX-OpenRouter-Signaturex-sume-webhook-signature
Header shapet=<timestamp>,v1=<hex>sume-v1=<hex>, comma-separated during a rotation
Timestamp locationInside the signature headerSeparate x-sume-webhook-timestamp header
Signed string{timestamp},{raw_body}{timestamp}.{raw_body}
Replay window in the docs5 minutes in the sample code5 minutes suggested, 300 s default in the SDK
Secret sourceWorkspace settingsDashboard Webhooks tab or GET /v1/webhooks/signing-secret

One Python verifier for both

The code builds both headers from a test secret and checks that each verifier accepts its own scheme and rejects the other. Both functions return False for an empty secret. I ran it locally.

import hashlib
import hmac

def _hex(secret: str, signed: str) -> str:
    return hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()

def verify_sume(secret: str, ts: str, header: str, body: str) -> bool:
    if not secret:
        return False
    want = "sume-v1=" + _hex(secret, f"{ts}.{body}")  # dot
    return any(hmac.compare_digest(p.strip(), want) for p in header.split(","))

def verify_openrouter(secret: str, header: str, body: str) -> bool:
    if not secret:
        return False
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    if "t" not in parts or "v1" not in parts:
        return False
    want = _hex(secret, f"{parts['t']},{body}")  # comma
    return hmac.compare_digest(parts["v1"], want)

body = '{"id":"abc123"}'
sume_header = "sume-v1=" + _hex("s3cret", f"1780000000.{body}")
or_header = "t=1780000000,v1=" + _hex("s3cret", f"1780000000,{body}")
print(verify_sume("s3cret", "1780000000", sume_header, body))        # True
print(verify_openrouter("s3cret", or_header, body))                  # True
print(verify_sume("s3cret", "1780000000", or_header, body))          # False

Rotation changes only the Sume side

For 24 hours after a rotation Sume signs each delivery with both secrets and puts two sume-v1= entries in the header, newest first. The verifier above already splits on commas and accepts any match. A verifier that compares the header for equality fails every delivery in that window, as the SDK page warns.

If a Sume signature does not verify, compare x-sume-webhook-secret-fingerprint with the fingerprint next to the secret in the dashboard. Neither side has to send the secret.

Why the separator matters

A verifier that builds timestamp + '.' + body will reject every OpenRouter delivery, and one that builds timestamp + ',' + body will reject every Sume delivery. Both fail closed, which is safe, but it looks like a broken secret. If both fail on one route, check the separator first.

Always compare with a constant-time function, and refuse an empty secret before computing anything. On the Sume side, the header may hold several comma-separated entries during the 24 hour rotation window, newest first, so accept a match on any entry.

Sources

Related posts

More in Comparisons

All Comparisons posts

Written by Sume