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.
- Re-render 300 old Sora prompts: queue capacity by plan
A 300-prompt backlog does not fit in one burst. Use Sume's accepted-job limits per plan, the wave_size_hint, and queue_full handling to pace the re-render.
- React Router 7 resource route as a Sume webhook receiver
A React Router 7 route module with only an action is a resource route. Read request.text(), call verifyWebhook from @sume-com/sdk, answer 204, branch on event.
- React Router resource route for Sume webhooks: request.text() first
In React Router or Remix, a resource route with only an action can verify a Sume webhook: read request.text(), check the HMAC, then JSON.parse and store job_id.
Written by Sume