Skip to content

Verifying webhook signatures

Copy-paste signature verification recipes in multiple languages.

Why this matters

Anyone who finds your webhook URL can POST whatever they want to it. The signature verification is what proves the request actually came from Revento and wasn't tampered with in flight. Skip verification → an attacker can fake application.approved for an application_id they control and trigger your handler.

Take verification seriously: constant-time comparison, replay protection, raw bytes (not parsed JSON).

The signing recipe

Revento computes the signature as:

signature = "sha256=" + hex(HMAC_SHA256(secret, timestamp + "." + body))

Where:

  • secret is your integration's HMAC signing secret (one per integration).
  • timestamp is the value of the X-Revento-Timestamp header (Unix epoch seconds, as a string).
  • body is the raw request body bytes — exactly what was POSTed, byte-for-byte.

The literal . between timestamp and body is a separator.

Your verifier reproduces this and compares. Five steps:

1. Read the headers

X-Revento-Timestamp: 1747000123
X-Revento-Signature: sha256=8e1c4b...

If either is missing, reject. (Fail-closed — never treat "no signature" as "unsigned but otherwise valid.")

2. Replay protection

abs(now - timestamp) > 300  →  reject

Five minutes is the documented window. Tighter is fine (down to ~30 seconds, accounting for clock skew); looser is your call but invites replay attacks.

3. Capture the raw body

Critical: parse the JSON after verification, never before. If your framework parses JSON automatically, you typically need to access raw bytes via a different API:

Framework How
Express (Node) express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })
Flask (Python) request.get_data() (NOT request.json or request.get_json())
FastAPI await request.body()
Rails request.raw_post
Go net/http body, _ := io.ReadAll(r.Body)
Sinatra request.body.read
Symfony $request->getContent()

If you parse JSON first, your runtime might re-emit different whitespace, key order, or numeric formatting when you stringify it back, breaking signature match. Always raw bytes.

4. Compute and compare

expected = "sha256=" + hex(HMAC_SHA256(secret, timestamp + "." + raw_body))

Use constant-time comparison for the comparison itself:

Language Function
Node crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual))
Python hmac.compare_digest(expected, actual)
Go hmac.Equal([]byte(expected), []byte(actual))
Ruby Rack::Utils.secure_compare(expected, actual) or OpenSSL.fixed_length_secure_compare
PHP hash_equals($expected, $actual)
Java MessageDigest.isEqual(expected.getBytes(), actual.getBytes())
C# CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes)

Plain == is not safe — short-circuit comparisons leak timing information that lets attackers guess the signature byte-by-byte. Use the framework function.

5. Mismatch → reject

If the comparison fails (or lengths differ), reject with 401 Unauthorized. Don't process the body.

Recipes

Node / TypeScript

import crypto from 'node:crypto';

function verifyWebhook(req, secret) {
  const timestamp = req.headers['x-revento-timestamp'];
  const signature = req.headers['x-revento-signature'];

  if (!timestamp || !signature) return false;

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (ageSeconds > 300) return false;

  // Two .update() calls instead of a template literal — template-literal
  // concatenation would coerce req.rawBody (Buffer) to a UTF-8 string and
  // corrupt non-UTF-8 bytes. This is exactly the failure mode warned about
  // above; do NOT collapse this into one .update() call.
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(`${timestamp}.`);
  hmac.update(req.rawBody);
  const expected = 'sha256=' + hmac.digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Set up raw body capture in your Express app:

app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; }
}));

Python / Flask

import hmac
import hashlib
from time import time
from flask import request

def verify_webhook(secret: bytes) -> bool:
    timestamp = request.headers.get("X-Revento-Timestamp", "")
    signature = request.headers.get("X-Revento-Signature", "")

    if not timestamp or not signature:
        return False

    try:
        if abs(time() - int(timestamp)) > 300:
            return False
    except ValueError:
        return False

    raw_body = request.get_data()  # raw bytes

    expected = "sha256=" + hmac.new(
        secret,
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "strconv"
    "time"
)

func verifyWebhook(r *http.Request, secret []byte) (bool, []byte, error) {
    ts := r.Header.Get("X-Revento-Timestamp")
    sig := r.Header.Get("X-Revento-Signature")
    if ts == "" || sig == "" {
        return false, nil, nil
    }

    tsInt, err := strconv.ParseInt(ts, 10, 64)
    if err != nil {
        return false, nil, err
    }
    if abs(time.Now().Unix()-tsInt) > 300 {
        return false, nil, nil
    }

    body, err := io.ReadAll(r.Body)
    if err != nil {
        return false, nil, err
    }

    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(ts + "."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(sig)), body, nil
}

func abs(x int64) int64 {
    if x < 0 {
        return -x
    }
    return x
}

PHP

function verifyWebhook(string $secret): bool {
    $timestamp = $_SERVER['HTTP_X_REVENTO_TIMESTAMP'] ?? '';
    $signature = $_SERVER['HTTP_X_REVENTO_SIGNATURE'] ?? '';

    if (!$timestamp || !$signature) return false;

    if (abs(time() - (int)$timestamp) > 300) return false;

    $body = file_get_contents('php://input');  // raw bytes

    $expected = 'sha256=' . hash_hmac('sha256', "{$timestamp}.{$body}", $secret);

    return hash_equals($expected, $signature);
}

Ruby

require 'openssl'
require 'rack/utils'

def verify_webhook(request, secret)
  timestamp = request.env['HTTP_X_REVENTO_TIMESTAMP']
  signature = request.env['HTTP_X_REVENTO_SIGNATURE']
  return false unless timestamp && signature
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  body = request.body.read
  request.body.rewind  # for downstream readers

  expected = 'sha256=' + OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{body}")

  Rack::Utils.secure_compare(expected, signature)
end

Rotation overlap

During a secret rotation, Revento sends two X-Revento-Signature headers — one for each secret — for 24 hours. Your verifier should accept either:

def verify_with_rotation(secrets: list[bytes]) -> bool:
    sig = request.headers.get("X-Revento-Signature", "")
    raw = request.get_data()
    ts  = request.headers.get("X-Revento-Timestamp", "")

    for secret in secrets:
        expected = "sha256=" + hmac.new(
            secret, f"{ts}.".encode() + raw, hashlib.sha256
        ).hexdigest()
        if hmac.compare_digest(expected, sig):
            return True
    return False

If only one secret is configured, secrets = [current] — same code path.

Common pitfalls

  • Comparing with == instead of constant-time function. Timing attack vector. Use the framework function.
  • Parsing JSON before verifying. Whitespace and key-order differences break signature match. Capture raw bytes first.
  • Skipping the replay window. Even with a valid signature, accepting old deliveries indefinitely lets an attacker replay a captured delivery later.
  • Short-circuiting on missing headers. Both X-Revento-Timestamp AND X-Revento-Signature must be present. If either is missing, reject.
  • Using a stale secret after rotation. During a 24-hour rotation overlap window your verifier must accept either the old or the new secret. Outside the overlap, only the new secret is valid.

Testing your verifier

Before you go to production, exercise your verifier with:

  • A genuinely valid delivery — should accept.
  • Modified body (one byte flipped) — should reject.
  • Modified timestamp — should reject.
  • Modified signature — should reject.
  • 6-minute-old timestamp — should reject (replay protection).
  • Missing headers — should reject.
  • Wrong secret — should reject.

If any of those don't behave correctly, fix before launch.