Verify webhook signatures
Verify X-Revoply-Signature in Node.js, Python, PHP or C#, get the raw body in Express, Flask, Laravel and ASP.NET, and debug failures.
Every webhook request carries X-Revoply-Signature: t=…,v1=…. Verifying it proves the
request came from us, was not changed, and is recent. The rules are on
Signatures; this guide puts them into code.
1. Add a verifier
Copy the verifier for your language into your project. Each is a single function with no dependencies, and each passes the published test vectors.
// Verifies the X-Revoply-Signature header on a RevoplyAI webhook (Node.js 18+, no packages).
//
// X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs]
//
// v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included.
// Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** How far the signed time may be from your clock before the request is refused. */
export const TOLERANCE_SECONDS = 300;
/**
* @param {Buffer | string} rawBody The body exactly as it arrived, before any JSON parsing.
* @param {string | undefined} header The X-Revoply-Signature header.
* @param {string | string[]} secrets Your whsec_… secret. While you switch to a rotated
* secret, pass both.
* @param {{ now?: number, toleranceSeconds?: number }} [options] `now` in Unix seconds.
* @returns {boolean}
*/
export function verifyRevoplySignature(rawBody, header, secrets, options = {}) {
if (typeof header !== 'string' || header.length === 0) return false;
let timestamp;
const signatures = [];
for (const part of header.split(',')) {
const index = part.indexOf('=');
if (index === -1) continue;
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === 'v1') signatures.push(value);
}
if (timestamp === undefined || signatures.length === 0) return false;
const now = options.now ?? Math.floor(Date.now() / 1000);
const tolerance = options.toleranceSeconds ?? TOLERANCE_SECONDS;
if (Math.abs(now - timestamp) > tolerance) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');
for (const secret of [secrets].flat()) {
if (!secret) continue;
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'),
);
for (const signature of signatures) {
const given = Buffer.from(signature);
if (given.length === expected.length && timingSafeEqual(given, expected)) return true;
}
}
return false;
}
2. Get the raw body
The signature covers the body exactly as we sent it. Most frameworks parse JSON before your handler runs, and a body that was parsed and serialised again differs from ours in key order, spacing or the escaping of Arabic text, so it never verifies. Read the raw bytes instead.
Express
Use express.raw() on the webhook route, registered before any global
app.use(express.json()). req.body is then a Buffer.
// express.raw() on this route, registered before any app.use(express.json()): the signature
// covers the exact bytes we sent, and a parsed-then-re-serialised body is not those bytes.
app.post('/webhooks/revoply', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyRevoplySignature(req.body, req.get('X-Revoply-Signature'), secret)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// The same event can arrive more than once: acknowledge a repeat and do nothing.
if (handled.has(event.id)) return res.sendStatus(200);
handled.add(event.id);
// Answer within 10 seconds, then do the work.
res.sendStatus(200);
setImmediate(() => handleEvent(event));
});Flask
Use request.get_data(), never json.dumps(request.json).
@app.post("/webhooks/revoply")
def revoply_webhook():
# get_data() is the body as it arrived. Never verify json.dumps(request.json):
# re-serialised JSON is not the bytes we signed.
raw_body = request.get_data()
if not verify_revoply_signature(raw_body, request.headers.get("X-Revoply-Signature"), SECRET):
return "Invalid signature", 400
event = json.loads(raw_body)
if event["id"] in handled:
return "", 200
handled.add(event["id"])
# Answer within 10 seconds; hand slow work to a queue.
print(event["type"], "(test)" if event["test"] else "")
return "", 200Laravel
Use $request->getContent(), never json_encode($request->all()). Put the route in
routes/api.php, which has no CSRF check.
public function __invoke(Request $request)
{
// getContent() is the body as it arrived. Never verify json_encode($request->all()):
// re-encoded JSON is not the bytes we signed.
$rawBody = $request->getContent();
if (!revoply_verify_signature($rawBody, $request->header('X-Revoply-Signature'), (string) config('services.revoply.webhook_secret'))) {
return response('Invalid signature', 400);
}
$event = json_decode($rawBody, true);
// The same event can arrive more than once: acknowledge a repeat and do nothing.
if (!Cache::add('revoply-event:' . $event['id'], true, now()->addDays(30))) {
return response('', 200);
}
// Answer within 10 seconds; queue slow work (dispatch a job) instead of doing it here.
logger()->info('RevoplyAI event', ['type' => $event['type'], 'test' => $event['test']]);
return response('', 200);
}In plain PHP, read php://input; $_POST is empty for JSON bodies.
// php://input is the body as it arrived; $_POST is empty for JSON.
$rawBody = file_get_contents('php://input');
if (!revoply_verify_signature($rawBody, $_SERVER['HTTP_X_REVOPLY_SIGNATURE'] ?? null, (string) getenv('REVOPLY_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// Deduplicate on $event['id'] in your database, then answer within 10 seconds.
error_log(sprintf('RevoplyAI %s %s%s', $event['id'], $event['type'], $event['test'] ? ' (test)' : ''));
http_response_code(200);ASP.NET Core
Take HttpRequest rather than binding a model with [FromBody], call
EnableBuffering(), copy the body, then rewind it so later code can read it again.
app.MapPost("/webhooks/revoply", async (HttpRequest request) =>
{
// Verify the bytes that arrived. If anything reads the body before this handler
// ([FromBody] binding, a logging middleware), buffer it and rewind, or there is
// nothing left to verify.
request.EnableBuffering();
using var copy = new MemoryStream();
await request.Body.CopyToAsync(copy);
request.Body.Position = 0;
var rawBody = copy.ToArray();
string? header = request.Headers["X-Revoply-Signature"];
if (!RevoplySignature.Verify(rawBody, header, [secret]))
{
return Results.BadRequest();
}
using var envelope = JsonDocument.Parse(rawBody);
var id = envelope.RootElement.GetProperty("id").GetString()!;
var type = envelope.RootElement.GetProperty("type").GetString();
var isTest = envelope.RootElement.GetProperty("test").GetBoolean();
// The same event can arrive more than once: acknowledge a repeat and do nothing.
if (!Handled.TryAdd(id, true))
{
return Results.Ok();
}
// Answer within 10 seconds; queue the real work instead of doing it here.
Console.WriteLine($"{id} {type}{(isTest ? " (test)" : "")}");
return Results.Ok();
});3. Test it
- Run your verifier against the vectors: for each entry in
webhook-signature-vectors.json, verifyheaderagainstbodywith each secret inverifyWith, at the timenow, and compare the result withvalid. - Send a test from Integrations → Webhooks → Send test. The dashboard shows what your endpoint answered.
- Change one character of the secret in your configuration and send another test: your
receiver must answer
400.
When verification fails
| Symptom | Cause |
|---|---|
| Fails for every request | The body was parsed before verification; see step 2. |
| Fails for every request | The key is not the whole secret. Use the string as shown, whsec_ included, as UTF-8; do not base64-decode it. |
| Fails for every request | The wrong endpoint's secret: each endpoint has its own. |
| Fails only for messages with Arabic text | The body was decoded and re-encoded in another encoding. Hash the bytes as received. |
| Fails every time, with a correct HMAC | The server's clock is more than 5 minutes off. Synchronise it. |
| Fails after a rotation | The new secret is not deployed yet. Accept both secrets during the switch. |
| Fails only behind one proxy | The proxy rewrites the body (compression, re-encoding). Verify before it, or pass the body through untouched. |
Compare signatures in constant time, as the verifiers above do: == on strings leaks how
much of a guess was right.