Replicate's default webhook secret versus Sume's signing secret

Replicate's secret is at /v1/webhooks/default/secret and it signs id.timestamp.body. Sume's is at /v1/webhooks/signing-secret and signs timestamp.raw_body.

4 min readSume
All posts

Both services sign webhooks with HMAC-SHA256, but the signed strings and the secret endpoints differ, so a verifier for one will not pass the other. Replicate signs ${id}.${timestamp}.${body} with the key that follows whsec_, and you fetch the secret from GET https://api.replicate.com/v1/webhooks/default/secret. Sume signs <timestamp>.<raw_body>, prefixes the hex with sume-v1=, and you fetch the secret from GET /v1/webhooks/signing-secret with an account:read key, or reveal it on the dashboard Webhooks tab.

Side by side

Webhook signing, read 2026-10-05
ItemReplicateSume
Headerswebhook-id, webhook-timestamp, webhook-signaturex-sume-webhook-timestamp, x-sume-webhook-signature, x-sume-webhook-secret-fingerprint
Signed string${id}.${timestamp}.${body}<timestamp>.<raw_body>
KeySecret after the whsec_ prefixThe workspace signing secret
Secret endpointGET /v1/webhooks/default/secretGET /v1/webhooks/signing-secret (account:read)
Signature formatAs the header gives itsume-v1=<hex>, several entries during rotation
Replay windowNot covered here300 seconds recommended

Two Sume details that bite

First, rotation: during a secret rotation the signature header carries one sume-v1= entry for each live secret, newest first, comma-separated. Accept the delivery if any entry matches. Second, always verify the raw body bytes before you parse JSON; re-serialized JSON will not match. If a signature fails, compare x-sume-webhook-secret-fingerprint with the fingerprint next to the secret in the dashboard, which avoids sending the secret itself.

Job webhooks and run webhooks share one secret and one signature scheme, so one verifier covers both.

A Sume verifier in Python

This refuses an empty secret, checks the 300-second tolerance, and compares every entry in constant time. The demo signs a body itself so you can run it.

import asyncio, hashlib, hmac, time

def verify(raw: bytes, ts: str, header: str, secret: str, tol: int = 300) -> bool:
    if not secret:
        raise ValueError("empty signing secret")
    try:
        t = int(ts)
    except ValueError:
        return False
    if abs(time.time() - t) > tol:
        return False
    digest = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    want = f"sume-v1={digest}"
    ok = False
    for entry in header.split(","):
        ok |= hmac.compare_digest(entry.strip(), want)
    return ok

async def main():
    body, secret, ts = b'{"event":"job.completed"}', "test-secret", str(int(time.time()))
    sig = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    print(verify(body, ts, f"sume-v1={sig}", secret))

asyncio.run(main())

Migrating a receiver

  • Branch on the header names, not on the URL, if one endpoint receives both vendors.
  • Keep each secret under its own name. Sume's docs use SUME_COM_WEBHOOK_SIGNING_SECRET.
  • Send a test delivery with POST /v1/webhooks/test-deliveries before you go live.

Testing a receiver

Sume has a test endpoint, POST /v1/webhooks/test-deliveries, so you can see a signed delivery arrive before a real job finishes. For a real job that you missed, POST /v1/jobs/{job_id}/webhook/redeliver with jobs:write sends it again, and a redelivery does not consume one of the attempts. The delivery policy is up to 10 attempts, a fixed 30 second delay, a 10 second timeout, public HTTPS only, and redirects are not followed.

Replicate's docs say inputs and outputs are deleted after one hour. Treat that as a reason to store results as soon as you verify the delivery, whichever vendor sends it.

Sources

Related posts

More in Comparisons

All Comparisons posts

Written by Sume