Authentication
In the v2 format, Rapid signs each webhook with HMAC-SHA256 using your signing key (the secret). Rapid generates this key when you register the webhook and shows it only once in the dashboard, under Settings › Webhook; store it right away. There is no way to recover the key later: if you lose it, generate another one in Settings › API (the previous one stops working at that same moment). Your application must validate this signature before processing the payload; that is how you confirm the POST came from Rapid.
Accounts in the
legacyformat (migrated from the previous system) do not receive a signature: authentication is done through theclientid/clientkeyheaders, as before. Check which format you have in Payload.
Signature header
Section titled “Signature header”X-Webhook-Signature: sha256=<hmac_hex>Where <hmac_hex> is the HMAC-SHA256, in hexadecimal, of the string ${timestamp}.${raw_body}, that is, the value of the X-Webhook-Timestamp header (seconds since the epoch), a dot, and the raw request body (the JSON received, byte for byte), using your signing key.
The timestamp is part of the signature to provide replay protection: without it, a captured valid POST could be resent indefinitely with a correct signature. Reject requests whose X-Webhook-Timestamp falls outside a tolerance window (we recommend 5 minutes).
How to validate
Section titled “How to validate”- Read the raw request body. Do not parse it as JSON before validating: the signature is computed over the exact bytes received.
- Build
signed = timestamp + "." + raw_body, computeHMAC-SHA256(secret, signed)and encode it in hexadecimal - Compare it with the value in
X-Webhook-Signature(after thesha256=prefix) - Use a timing-safe comparison to avoid timing attacks
Examples
Section titled “Examples”A complete endpoint in each language: it reads the raw body, validates the signature and the time window, and only then reads the JSON. It responds 401 to any delivery that does not pass, including one without the headers. The key comes from the RAPID_WEBHOOK_SECRET environment variable.
import crypto from 'node:crypto'import express from 'express'
const TOLERANCE_SECONDS = 5 * 60
function validateSignature(rawBody, signature, timestamp, secret) { // 1. Tolerance window (anti-replay). Without it, a captured old POST // keeps passing forever. const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false
// 2. Sign `timestamp.raw_body` (the body as received, not re-serialized). const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(expected) const b = Buffer.from(signature ?? '') return a.length === b.length && crypto.timingSafeEqual(a, b)}
const app = express()
// express.raw, not express.json: the signature is computed over the raw body.app.post('/webhooks/rapid', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8') const ok = validateSignature( rawBody, req.get('X-Webhook-Signature'), req.get('X-Webhook-Timestamp'), process.env.RAPID_WEBHOOK_SECRET, ) if (!ok) return res.status(401).end()
const event = JSON.parse(rawBody) // Process the `event` (preferably in a queue) and respond quickly. res.status(200).end()})
app.listen(process.env.PORT ?? 3000)import hashlibimport hmacimport osimport time
from flask import Flask, request
TOLERANCE_SECONDS = 5 * 60
def validate_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool: # 1. Tolerance window (anti-replay). try: age = abs(int(time.time()) - int(timestamp)) except (TypeError, ValueError): return False if age > TOLERANCE_SECONDS or not signature: return False
# 2. Sign `timestamp.raw_body` (the bytes received, not re-serialized). signed = timestamp.encode() + b"." + raw_body expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), signature.encode())
app = Flask(__name__)
@app.post("/webhooks/rapid")def rapid_webhook(): # get_data(), not get_json(): the signature is computed over the raw body. raw_body = request.get_data() ok = validate_signature( raw_body, request.headers.get("X-Webhook-Signature", ""), request.headers.get("X-Webhook-Timestamp", ""), os.environ["RAPID_WEBHOOK_SECRET"], ) if not ok: return "", 401
event = request.get_json() # Process the `event` (preferably in a queue) and respond quickly. return "", 200<?phpconst TOLERANCE_SECONDS = 5 * 60;
function validateSignature(string $rawBody, string $signature, string $timestamp, string $secret): bool{ // 1. Tolerance window (anti-replay). if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) { return false; }
// 2. Sign `timestamp.raw_body` (the bytes received, not re-serialized). $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature);}
// php://input, not json_decode first: the signature is computed over the raw body.$rawBody = file_get_contents('php://input');$ok = validateSignature( $rawBody, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '', $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '', getenv('RAPID_WEBHOOK_SECRET'));if (!$ok) { http_response_code(401); exit;}
$event = json_decode($rawBody, true);// Process the $event (preferably in a queue) and respond quickly.http_response_code(200);Signature checker
Section titled “Signature checker”Your validation does not match? Paste what you received here and compare it with the expected signature.
The calculation runs in your browser: nothing you paste here leaves this page. Even so, prefer testing with the key of a test webhook.
Secret rotation
Section titled “Secret rotation”You generate a new key yourself in the dashboard, under Settings › API. It is shown only once, and from then on webhooks go out signed with it, with no overlap period: whoever validates with the previous key starts rejecting. Change the key on your servers right after, preferably in a low-traffic window.
Saving a new URL does not touch the key.
Legacy headers
Section titled “Legacy headers”In the legacy format, Rapid sends the clientid and clientkey headers and does not send a signature, exactly like the previous system.
clientid: <client_id>clientkey: <client_secret>In the v2 format these headers do not exist: validating by clientid/clientkey would mean receiving the API access credential on every request (less secure than signing the payload), so delivery authentication is only X-Webhook-Signature. Accounts on legacy keep receiving the headers indefinitely, so existing integrations do not break.