Guide to Railway Webhooks: Features and Best Practices
Railway webhooks tell you what is happening to your deployments. When a service's deployment changes status, for example when it fails, or when a volume is filling up or a service crosses a CPU or memory threshold, Railway can notify your own application. Teams use them to post deploy results where they work, open incidents for failed deploys, trigger smoke tests and keep a record of what shipped.
This guide covers how Railway webhooks are configured, what the payload contains, how to authenticate requests with a custom header, Railway's retry and pause rules, and the practices that keep a Railway receiver dependable.
What are Railway webhooks?
Railway webhooks are configured per project. You give a webhook a URL, optionally choose which events it should receive, and optionally add custom headers. Railway then sends a JSON POST whenever a service's deployment status changes or an alert fires, across every environment in the project.
Railway says plainly that webhook payloads are not cryptographically signed. Its recommended way to confirm a request came from Railway is a custom header with a secret value, such as Authorization: Bearer <token> or X-Webhook-Secret, and rejecting any request without it.
Railway webhook features
| Feature | Details |
|---|---|
| Configuration | Project > Settings > Webhooks, the MCP server, or the public API |
| Scope | One project, all of its environments |
| Events | Deployment status changes, volume usage alerts, CPU/RAM monitor alerts |
| Method and format | POST, JSON body |
| Event field | type, for example Deployment.failed |
| Custom headers | Up to 20, sent with every delivery and with Test Webhook |
| Header values | Encrypted and write-only once saved |
| Authentication | None built in. Railway recommends a secret custom header |
| Signature and timestamp | No signature. The body has a timestamp field, but nothing signs it |
| Timeout | 30 seconds per attempt (10 seconds for Test Webhook) |
| Retries | Up to 3, with exponential backoff |
| Success | Any 2xx or 3xx response |
| Guarantees | Best-effort and unordered |
| Pause | 100 failures in 6 hours stops sending to the URL for 24 hours |
| Source IPs | Not published |
The webhook payload
Railway's example payload, with its placeholder values:
{
"type": "Deployment.failed",
"details": {
"id": "8107edff-4b8e-44fc-b43a-04566e847a2a",
"source": "GitHub",
"status": "SUCCESS",
"branch": "...",
"commitHash": "...",
"commitAuthor": "...",
"commitMessage": "..."
},
"resource": {
"workspace": { "id": "<workspace id>", "name": "<workspace name>" },
"project": { "id": "<project id>", "name": "<project name>" },
"environment": { "id": "<environment id>", "name": "<environment name>", "isEphemeral": false },
"service": { "id": "<service id>", "name": "<service name>" },
"deployment": { "id": "<deployment id>" }
},
"severity": "WARNING",
"timestamp": "2025-11-21T23:48:42.311Z"
}
type names the event as <Resource>.<state>. Railway does not list every type value. Deployment events follow the deployment states in Railway's Deployments reference, and the alert types are not enumerated, so route on the prefix and log anything you have not seen. resource tells you exactly where the event happened, down to the environment, and isEphemeral tells you whether the environment is ephemeral. Note that the published example pairs "type": "Deployment.failed" with "status": "SUCCESS" in details, so read the state from type rather than assuming the two agree.
If the webhook URL is a Discord or Slack webhook, Railway rewrites the payload for that service. Those requests are not the format above.
Inspect Railway webhooks as they arrive. Paste a Hookdeck Inspect URL into a project's webhook settings and click Test Webhook to see the exact payload and headers, with no account or setup required.
Setting up Railway webhooks
Generate a long random secret first, for example with openssl rand -hex 32.
- Open the project in Railway and click Settings in the top right.
- Open the Webhooks tab and enter your HTTPS endpoint URL.
- In Custom headers, add a row with a name such as
X-Webhook-Secretand your secret as the value. To useAuthorizationinstead, type the value asBearer <secret>. - Optionally choose which events to receive.
- Click Test Webhook to send a sample with your headers, then Save Webhook.
Header names can use letters, digits, - and _, up to 128 characters. Values can be up to 4096 characters with no line breaks. Railway refuses names it sets itself: Host, Content-Type, Content-Length, the hop-by-hop headers, and anything starting with Proxy- or X-Railway-. After you save, the value shows as Unchanged and cannot be read back, so store it in your secrets manager first.
To route through Hookdeck, create a Hookdeck source with the Railway type and use its URL as the webhook URL. The source asks for an API Key Header and an API Key: enter the same name and the whole value, including Bearer if you typed it. Names match case-insensitively, values exactly. The source accepts POST, which is the method Railway uses. It checks a header only, so do not rely on Railway's fallback of a secret in the URL path or query string.
Securing Railway webhooks
Read the header you chose, compare its whole value with your stored secret in constant time, and reject the request if either is missing.
const crypto = require('crypto');
const express = require('express');
const HEADER_NAME = (process.env.RAILWAY_WEBHOOK_HEADER || 'x-webhook-secret').toLowerCase();
const SECRET = process.env.RAILWAY_WEBHOOK_SECRET;
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on unequal lengths, so check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/webhooks/railway', express.raw({ type: '*/*' }), (req, res) => {
const received = req.get(HEADER_NAME);
if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
return res.sendStatus(401);
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
const [kind, state] = String(event.type || '').split('.');
if (kind === 'Deployment') {
// state is the deployment state, e.g. "failed".
// event.resource.service.name and event.resource.environment.name say where.
}
res.sendStatus(200);
});
The same check in Python:
import hmac
import os
HEADER_NAME = os.environ.get("RAILWAY_WEBHOOK_HEADER", "X-Webhook-Secret")
SECRET = os.environ.get("RAILWAY_WEBHOOK_SECRET", "")
def verify_railway_request(headers) -> bool:
if not SECRET:
return False # never accept when unconfigured
received = headers.get(HEADER_NAME) # Starlette and Flask headers are case-insensitive
if not received:
return False
# Compare bytes: compare_digest raises TypeError on a non-ASCII str.
return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))
A static header authenticates the sender only. It does not protect the body, and the timestamp field is not signed, so a captured request can be replayed and will still pass. Use HTTPS, a long random value, and rotate it in Railway and your environment together if it might have leaked.
Make Railway webhooks production-ready. Hookdeck Event Gateway checks the custom header you configure, accepts every deployment and alert event straight away, and retries delivery to your service so Railway never pauses your URL.
Railway webhook limitations and pain points
A failing URL is paused for a day
The Problem: After a bad stretch, Railway stops sending to your URL for 24 hours, so you miss every deploy failure and alert in that window.
Why It Happens: If a URL fails 100 times within 6 hours, Railway temporarily stops sending to it rather than spend retries on a dead endpoint. With up to three retries per event, a busy project can reach that quickly.
Workarounds:
- Keep the endpoint thin and highly available, and respond before doing any work.
- Alert on errors from your endpoint so you notice well before the threshold.
How Hookdeck Can Help: Hookdeck accepts each event as soon as it arrives, so failures in your service do not count against Railway's threshold. Delivery to your service uses retries you configure, and you can replay any event once you have fixed the problem.
Best-effort and unordered
The Problem: A Deployment event goes missing, or a later state arrives before an earlier one.
Why It Happens: Railway says delivery is not guaranteed and events are not ordered, and recommends treating a webhook as a prompt to act rather than a source of truth.
Workarounds:
- Use the event as a trigger to read the deployment's current state from Railway's public API.
- Compare
timestampwith the last update you stored before overwriting state.
How Hookdeck Can Help: Hookdeck stores every event it receives, so nothing that reached it is lost to an outage on your side, and you can inspect and replay the full history.
No signature
The Problem: Nothing proves a request body came from Railway unchanged.
Why It Happens: Railway does not sign payloads. Its only recommended check is the custom header.
Workarounds:
- Use a long random header value over HTTPS. Avoid putting the secret in the URL, which Railway notes tends to end up in access logs.
- Before acting on anything consequential, such as rolling back, confirm the deployment's state through the API.
How Hookdeck Can Help: Hookdeck's Railway source compares the header you name with the exact value you enter. Hookdeck answers with a 200 either way, but requests that fail the check are recorded as failed verification and never reach your service.
An open-ended set of type values
The Problem: A handler that switches on a fixed list of type strings drops events it does not know.
Why It Happens: Railway documents the categories of events, but not every type value, and new states or alert types can appear.
Workarounds:
- Route on the part before the dot, then handle the state.
- Acknowledge unknown types with a
200and log them.
How Hookdeck Can Help: Hookdeck filters can route on type, severity or any resource field, for example sending only production Deployment failures to your paging tool.
Best practices
Filter by environment
Webhooks fire for every environment in the project, including ephemeral ones. Check resource.environment.name and isEphemeral before you page anyone.
Deduplicate on the deployment and state
Retries can deliver the same event more than once. Key on resource.deployment.id plus type and timestamp. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Any 2xx or 3xx counts as success. Verify the header, store the event, return 200, and do the work afterwards. See why to process webhooks asynchronously.
Test with Test Webhook
Test Webhook sends a sample with your custom headers, from Railway's servers, with a 10-second timeout. Use it after every header change to confirm your receiver still accepts the request.
Conclusion
Railway project webhooks send unsigned JSON events for deployment status changes and resource alerts across every environment in a project. Add a secret custom header and compare it whole in constant time over HTTPS, route on the type prefix, filter by environment, treat each event as a prompt to check the API, and keep the endpoint healthy so 100 failures never pause your URL for a day.
Hookdeck Event Gateway checks the header, accepts every event immediately, and retries and replays delivery to your service, so a failing receiver no longer costs you a day of deployment notifications.
Get started with Hookdeck for free and handle Railway webhooks reliably in minutes.