Railway webhook custom headers vs Sume's signed headers

Railway project webhooks now accept custom headers. Sume signs its callbacks with x-sume-webhook-timestamp and x-sume-webhook-signature; verify them in Python.

4 min readSume
All posts

Railway's October 1, 2026 changelog lists custom headers for project webhooks. If a Railway deploy webhook starts a Sume job, you can use a custom header as a shared secret on that hop. Sume's own callbacks are different: they are signed with x-sume-webhook-timestamp and x-sume-webhook-signature, and you should verify both.

Two hops, two kinds of trust

The Railway changelog read on 2026-10-03 includes the line "MongoDB high availability, custom headers for project webhooks, MySQL PITR". It says nothing more about the feature here, so check Railway's own docs for how headers are configured.

Where each header applies (read 2026-10-03)
HopWho sends itWhat proves it is genuine
Railway project webhook to your serverRailwayA custom header you set, per the changelog
Sume job callback to your serverSumeHMAC signature over timestamp and raw body

What Sume sends

Job webhooks deliver job.completed, job.failed and job.canceled to a public HTTPS webhook_url. Sume signs the raw JSON body with HMAC SHA-256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>. During a secret rotation the signature header carries one entry per live secret, so accept the delivery when any sume-v1= entry matches. Reject timestamps outside your tolerance; five minutes is the suggested default.

Your signing secret is on the Webhooks tab of the dashboard, and the same secret covers job and run webhooks.

A verifier in Python

This refuses an empty secret, checks the timestamp window, and compares every entry in constant time. Pass the raw body bytes, not parsed JSON.

import hashlib, hmac, time

def verify(raw_body: bytes, timestamp: str, header: str, secret: str,
           tolerance: int = 300) -> bool:
    if not secret:
        return False
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - ts) > tolerance:
        return False
    digest = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body,
                      hashlib.sha256).hexdigest()
    expected = f"sume-v1={digest}"
    ok = False
    for entry in header.split(","):
        if hmac.compare_digest(entry.strip(), expected):
            ok = True
    return ok

Delivery behavior to plan for

Return a 2xx after durably storing the event. Sume retries up to 10 attempts with a fixed delay, 30 seconds by default, and a 10 second timeout per attempt. Use job_id as your idempotency key, and keep polling status_url as a backup because delivery is an optimization, not your only recovery path.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume