# 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:

```json
{
  "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](https://inspect.hookdeck.com) 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`.

1. Open the project in Railway and click **Settings** in the top right.
2. Open the **Webhooks** tab and enter your HTTPS endpoint URL.
3. In **Custom headers**, add a row with a name such as `X-Webhook-Secret` and your secret as the value. To use `Authorization` instead, type the value as `Bearer <secret>`.
4. Optionally choose which events to receive.
5. 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.

```javascript
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:

```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](/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](/docs/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 `timestamp` with 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 `200` and log them.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Railway webhooks reliably in minutes.