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.
How webhooks work
When something happens to a payment, Railbed sends a POST with a JSON event 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.
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 | The buyer entered their email and was given a way to pay |
payment.paid | The money arrived and passed Railbed's checks. Fulfil on this |
payment.held | Money arrived but failed a check, so it waits for your review |
payment.updated | A paid payment's settlement details were filled in |
payment.failed | The payment was declined (Test mode) |
payment.expired | Nobody paid in time |
payment.canceled | You canceled a payment link |
ping | You chose Send test event (or Send ping on a Live endpoint) |
Add an endpoint
- In the dashboard, choose Test or Live, then open Developers.
- 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.
- Copy the signing secret (
whsec_…) into your server's configuration, for exampleRAILBED_WEBHOOK_SECRET. You can reveal it again on the endpoint later. - 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.
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
3xxis a failure. Save the final address as the endpoint. - Reject what you can't verify with a
4xx, such as400. Deliveries that fail are retried, so a bad secret shows up in the log instead of losing events. - Don't answer
2xxbefore the event is saved. A2xxtells 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.
- Don't undo a paid order on a later event. A
payment.expiredthat arrives afterpayment.paiddoesn't make the payment unpaid. When in doubt, read the 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
pingor a sample of any payment event. The sample carries a made-up payment (metadata.sampleis"true") that doesn't exist in your account or the API. - Live endpoints can receive a
pingonly, 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. 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
Express
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]));
}Flask
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
$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 and grants the order once, as in Fulfil orders safely. Verification in more languages, and a test vector to check yours against, are in Verify signatures.
Checklist
- The endpoint verifies
Railbed-Signatureagainst the raw body and rejects anything else with a4xx - Timestamps older than five minutes are rejected
- Event ids are stored with a unique constraint, and duplicates are acknowledged with
2xxand ignored - The endpoint answers within a second or two and fulfils in the background
- Unknown event types are acknowledged with
2xxand ignored - Orders are granted once per payment, from
paidonly - 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