Send your first webhook
You need a project ID and an API key with the READ_WRITE scope (see Quickstart).
export RAILHOOK_URL=https://railhook.io # or your own instance, e.g. http://localhost or https://hooks.example.comexport RAILHOOK_API_KEY=...export PROJECT_ID=...-
Register an endpoint
Terminal window curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/endpoints" \-H "X-API-Key: $RAILHOOK_API_KEY" \-H "Content-Type: application/json" \-d '{"url":"https://api.customer.com/webhooks","description":"Orders"}'The response includes the endpoint’s
idand itssecret, which the receiver uses to check signatures. -
Subscribe it to an event type
Terminal window curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/subscriptions" \-H "X-API-Key: $RAILHOOK_API_KEY" \-H "Content-Type: application/json" \-d '{"endpointId":"'"$ENDPOINT_ID"'","eventType":"order.completed"}'Patterns work too:
order.*matches one segment,order.**matches any number. -
Send an event
Terminal window curl -X POST "$RAILHOOK_URL/api/v1/events" \-H "X-API-Key: $RAILHOOK_API_KEY" \-H "Content-Type: application/json" \-H "Idempotency-Key: order-12345-completed" \-d '{"type":"order.completed","data":{"orderId":"ord_12345","amount":99.99}}'The response is
201, witheventIdanddeliveriesCreated. -
Verify the signature on the receiver
The endpoint receives a
POSTwhose body is the event’sdataobject, plus these headers:What lands on the endpoint POST /webhooks HTTP/1.1Content-Type: application/jsonX-Signature: t=1738000000000,v1=<hex hmac-sha256>X-Timestamp: 1738000000000X-Event-Id: 6f0e…X-Delivery-Id: 91ab…{"orderId":"ord_12345","amount":99.99}The signature is HMAC-SHA256 over
<t>.<raw body>, keyed with the endpoint secret. Verify against the raw bytes, before you parse the JSON:import express from 'express';import { verifySignature } from '@railhook/node';const app = express();app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {try {verifySignature(req.body.toString(), req.headers['x-signature'], process.env.WEBHOOK_SECRET);} catch {return res.status(400).send('Invalid signature');}const data = JSON.parse(req.body.toString());res.sendStatus(200);});import osfrom flask import Flask, requestfrom railhook import RailhookError, verify_signatureapp = Flask(__name__)@app.route("/webhooks", methods=["POST"])def handle_webhook():payload = request.get_data(as_text=True)try:verify_signature(payload, request.headers.get("X-Signature", ""), os.environ["WEBHOOK_SECRET"])except RailhookError:return "Invalid signature", 400return "OK", 200<?phpuse Railhook\Webhook;use Railhook\Exception\RailhookException;$payload = file_get_contents('php://input');$headers = getallheaders();try {Webhook::verifySignature($payload, $headers['X-Signature'] ?? '', getenv('WEBHOOK_SECRET'));} catch (RailhookException $e) {http_response_code(400);exit('Invalid signature');}http_response_code(200);Terminal window # Recompute the signature from the raw body and compare it with v1.EXPECTED=$(printf '%s' "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)[ "$EXPECTED" = "$V1" ] || exit 1The SDKs also reject a timestamp older than five minutes by default, so a captured request cannot be replayed.