RevoplyAIDocs
Guides

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 "", 200

Laravel

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

  1. Run your verifier against the vectors: for each entry in webhook-signature-vectors.json, verify header against body with each secret in verifyWith, at the time now, and compare the result with valid.
  2. Send a test from Integrations → Webhooks → Send test. The dashboard shows what your endpoint answered.
  3. Change one character of the secret in your configuration and send another test: your receiver must answer 400.

When verification fails

SymptomCause
Fails for every requestThe body was parsed before verification; see step 2.
Fails for every requestThe key is not the whole secret. Use the string as shown, whsec_ included, as UTF-8; do not base64-decode it.
Fails for every requestThe wrong endpoint's secret: each endpoint has its own.
Fails only for messages with Arabic textThe body was decoded and re-encoded in another encoding. Hash the bytes as received.
Fails every time, with a correct HMACThe server's clock is more than 5 minutes off. Synchronise it.
Fails after a rotationThe new secret is not deployed yet. Accept both secrets during the switch.
Fails only behind one proxyThe 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.

On this page