# Webhooks

> Railbed sends a signed HTTPS request to your server when a payment starts, settles, is held, expires or is canceled. How endpoints, deliveries, retries and the delivery log work.

Source: https://railbed.io/docs/webhooks/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs

## How webhooks work

When something happens to a payment, Railbed sends a `POST` with a JSON [event](https://railbed.io/docs/webhooks/events.md) to each of your endpoints that subscribes to it. The request is signed with the endpoint's secret, so your server can prove it came from Railbed and wasn't changed.

One delivery:

```text
POST https://yourstore.com/webhooks/railbed
Content-Type: application/json
User-Agent: Railbed-Webhooks/1.0
Railbed-Signature: t=1790381342,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{"id":"evt_4Qm8ZsUe2VhNc7RwTb1Y","type":"payment.paid","created":1790381342,"livemode":true,"data":{"payment":{…}}}
```

Your server verifies the signature, records the event, answers `2xx` and does the slow work afterwards. Events are queued in the same step as the payment change itself, so a payment can't become paid without its webhook being queued. Most are sent as soon as the change is saved; expiries go out within five minutes.

| Event | Sent when |
|---|---|
| [`payment.started`](https://railbed.io/docs/webhooks/events.md#payment-started) | The buyer entered their email and was given a way to pay |
| [`payment.paid`](https://railbed.io/docs/webhooks/events.md#payment-paid) | The money arrived and passed Railbed's checks. **Fulfil on this** |
| [`payment.held`](https://railbed.io/docs/webhooks/events.md#payment-held) | Money arrived but failed a check, so it waits for your review |
| [`payment.updated`](https://railbed.io/docs/webhooks/events.md#payment-updated) | A paid payment's settlement details were filled in |
| [`payment.failed`](https://railbed.io/docs/webhooks/events.md#payment-failed) | The payment was declined (Test mode) |
| [`payment.expired`](https://railbed.io/docs/webhooks/events.md#payment-expired) | Nobody paid in time |
| [`payment.canceled`](https://railbed.io/docs/webhooks/events.md#payment-canceled) | You canceled a payment link |
| [`ping`](https://railbed.io/docs/webhooks/events.md#ping) | You chose **Send test event** (or **Send ping** on a Live endpoint) |

## Add an endpoint

1. In the dashboard, choose **Test** or **Live**, then open [Developers](https://app.railbed.io/developers).
2. Choose **Add endpoint** and enter your server's address. Add a description if you like ("Fulfilment server"), and pick the events it should receive. It gets every event unless you choose.
3. Copy the **signing secret** (`whsec_…`) into your server's configuration, for example `RAILBED_WEBHOOK_SECRET`. You can reveal it again on the endpoint later.
4. Choose **Send test event** (on a Live endpoint, **Send ping**) and check how your server answered.

Each mode has its own endpoints and secrets: Test endpoints receive only Test events, and Live endpoints only Live events. You can have up to 10 endpoints in each mode, each with a different address.

**Address rules.** Endpoints use `https://` with no username or password in the address. (Test endpoints also accept `http://localhost`, but Railbed's servers can't reach your computer that way.) Live endpoints must be on the public internet: private networks, `localhost` and internal hostnames are refused, and addresses are checked again at each send. While you build on your own computer, [use a tunnel](https://railbed.io/docs/testing.md#receive-webhooks-on-your-own-computer).

> [!NOTE]
> Endpoints added before September 26, 2026 receive only `payment.paid` and `payment.failed` until you edit them and choose their events, so existing integrations see no new traffic unannounced.

## Respond to deliveries

A delivery succeeds when your endpoint answers with any `2xx` status within **10 seconds**. The body of your answer is ignored.

- **Answer fast.** Verify, record the event, answer `200`, then fulfil in a background job. A slow fulfilment that runs past 10 seconds counts as a failure and is retried, even if it later finishes.
- **Redirects aren't followed.** A `3xx` is a failure. Save the final address as the endpoint.
- **Reject what you can't verify** with a `4xx`, such as `400`. Deliveries that fail are retried, so a bad secret shows up in the log instead of losing events.
- **Don't answer `2xx` before the event is saved.** A `2xx` tells Railbed to stop sending it.

## Retries

A delivery that fails is retried **1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours** after each failed attempt: seven attempts over about 21 hours. After the last one it's marked failed and stays in the delivery log, where you can send it again.

Every attempt carries the same event `id` and body, with a fresh signature and timestamp. When you edit the endpoint's address, pending retries go to the new one. When you roll its secret, later attempts are signed with the new secret.

## Duplicates and order

Webhooks are delivered at least once. The same event can arrive more than once (a retry after a timeout, a resend from the log), and events for one payment can arrive in a different order than they happened, even in the same second.

- **Deduplicate on the event `id`.** Store each id you process, with a unique constraint, in the same transaction as its effects.
- **Make fulfilment idempotent too.** Grant each order once per payment id, whatever event or job arrives first. See [Fulfil orders safely](https://railbed.io/docs/guides/fulfilment.md).
- **Don't undo a paid order on a later event.** A `payment.expired` that arrives after `payment.paid` doesn't make the payment unpaid. When in doubt, [read the payment](https://railbed.io/docs/api/payments.md#retrieve-a-payment): it's always current.

## The delivery log

**Deliveries** on each endpoint lists its latest 50 deliveries: the event, when it was sent, how many attempts it took, the status code or error your server answered with, when the next retry is, and the exact body that was sent. Filter it to **Failed** or **Retrying** to see what needs attention.

- **Retry now** sends a delivery that's still retrying straight away. If it fails again, its automatic retries carry on as before, so pressing it while your server is down never uses them up.
- **Resend** sends a finished delivery again, once, with the same event id.
- When the latest delivery to an endpoint failed or is retrying, its card says so and links to the log.

Finished deliveries are kept for 30 days. A payment's timeline in the dashboard also shows when its webhooks were delivered or failed.

## Test events

**Send test event** on a Test endpoint sends one signed event, straight away, and shows the body sent and how your server answered. On a Live endpoint the button is **Send ping**: it sends a ping and reports the answer. Test sends are never retried.

- **Test endpoints** can receive a `ping` or a sample of any payment event. The sample carries a made-up payment (`metadata.sample` is `"true"`) that doesn't exist in your account or the API.
- **Live endpoints** can receive a `ping` only, so a live system never receives a payment that didn't happen.

To test the whole flow, create a Test session through the API and [simulate its outcome](https://railbed.io/docs/testing.md). That sends the real sequence of events for a real Test payment.

## Manage endpoints

| Action | What happens |
|---|---|
| **Edit** | Change the address, the description or the events. Pending retries follow the new address |
| **Roll secret** | A new secret takes effect at once; the old one signs nothing further, retries included. Update your server straight away, or deliveries fail verification until you do |
| **Remove** | The endpoint stops receiving events, its pending retries stop, and its history is no longer listed |

## A receiver, end to end

Node.js · Express:

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// The raw body: verify exactly the bytes that were signed.
const rawJson = express.raw({ type: 'application/json' });

app.post('/webhooks/railbed', rawJson, async (req, res) => {
  const raw = req.body.toString('utf8');
  const secret = process.env.RAILBED_WEBHOOK_SECRET;
  const header = req.get('Railbed-Signature');
  if (!verify(raw, header, secret)) return res.sendStatus(400);

  const event = JSON.parse(raw);
  const isNew = await db.events.insertIfAbsent(event.id); // UNIQUE(id)
  if (isNew && event.type === 'payment.paid') {
    await queue.add('fulfil', { paymentId: event.data.payment.id });
  }
  res.sendStatus(200);
});

function verify(raw, header, secret) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
  if (!m) return false;
  if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${m[1]}.${raw}`)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
```

Python · Flask:

```python
import hashlib, hmac, os, re, time
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhooks/railbed")
def railbed_webhook():
    raw = request.get_data()  # the raw bytes, before any JSON parsing
    header = request.headers.get("Railbed-Signature", "")
    if not verify(raw, header, os.environ["RAILBED_WEBHOOK_SECRET"]):
        return "", 400
    event = request.get_json()
    is_new = db.events.insert_if_absent(event["id"])
    if is_new and event["type"] == "payment.paid":
        queue.enqueue("fulfil", event["data"]["payment"]["id"])
    return "", 200

def verify(raw: bytes, header: str, secret: str) -> bool:
    m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
    if not m:
        return False
    expected = hmac.new(
        secret.encode(), m[1].encode() + b"." + raw, hashlib.sha256
    ).hexdigest()
    return (
        abs(time.time() - int(m[1])) < 300
        and hmac.compare_digest(expected, m[2])
    )
```

PHP:

```php
<?php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_RAILBED_SIGNATURE'] ?? '';

if (!railbed_verify($raw, $header, getenv('RAILBED_WEBHOOK_SECRET'))) {
  http_response_code(400);
  exit;
}

$event = json_decode($raw, true);
if (events_insert_if_absent($event['id']) && $event['type'] === 'payment.paid') {
  queue_fulfilment($event['data']['payment']['id']);
}
http_response_code(200);

function railbed_verify(string $raw, string $header, string $secret): bool {
  if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) return false;
  $expected = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
  return abs(time() - (int) $m[1]) < 300 && hash_equals($expected, $m[2]);
}
```

The fulfilment job then [reads the payment](https://railbed.io/docs/api/payments.md#retrieve-a-payment) and grants the order once, as in [Fulfil orders safely](https://railbed.io/docs/guides/fulfilment.md). Verification in more languages, and a test vector to check yours against, are in [Verify signatures](https://railbed.io/docs/webhooks/signatures.md).

## Checklist

- [ ] The endpoint verifies `Railbed-Signature` against the **raw** body and rejects anything else with a `4xx`
- [ ] Timestamps older than five minutes are rejected
- [ ] Event ids are stored with a unique constraint, and duplicates are acknowledged with `2xx` and ignored
- [ ] The endpoint answers within a second or two and fulfils in the background
- [ ] Unknown event types are acknowledged with `2xx` and ignored
- [ ] Orders are granted once per payment, from `paid` only
- [ ] A background job checks orders still waiting, in case a webhook never arrives
- [ ] The Live endpoint uses the Live secret, and the Test endpoint the Test secret
