Skip to content

↑↓ navigate ↵ open Ctrl↵ new tab esc close

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 legacy format (migrated from the previous system) do not receive a signature: authentication is done through the clientid/clientkey headers, as before. Check which format you have in Payload.

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).


  1. Read the raw request body. Do not parse it as JSON before validating: the signature is computed over the exact bytes received.
  2. Build signed = timestamp + "." + raw_body, compute HMAC-SHA256(secret, signed) and encode it in hexadecimal
  3. Compare it with the value in X-Webhook-Signature (after the sha256= prefix)
  4. Use a timing-safe comparison to avoid timing attacks

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)

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.

Exactly as it arrived, before any JSON parsing.

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.


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.