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.

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.
| Hop | Who sends it | What proves it is genuine |
|---|---|---|
| Railway project webhook to your server | Railway | A custom header you set, per the changelog |
| Sume job callback to your server | Sume | HMAC 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 okDelivery 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
- Railway Sandboxes: run the Sume CLI with an env-var key
Inside a short-lived sandbox, install the Sume CLI with the hosted installer, pass SUME_API_KEY as an environment variable, and never print it.
- Recraft V4.1 Flash: median 1.3 s, p95 1.8 s. Set timeouts from p95
Recraft quotes a median of about 1.3 seconds and a p95 of 1.8 seconds for V4.1 Flash. How to turn latency claims into timeouts and polling for image APIs.
- Reproduce the same AI voiceover later: model id, voice, settings
To redo a narration line months later you need the model id, voice, language, format and settings. Sume's completed TTS job records them. A short routine.
- Retrain a cloned voice without breaking old videos
HeyGen keeps a voice ID when a clone is retrained. On Sume, an avatar is referenced by a stable handle, and each text-to-speech job records its voice and model.
Written by Sume