Signature verification and security

Webhook Signature verification HMAC Security Replay attack
Who this article is for
For developers implementing a Webhook endpoint. It explains how to verify that an arriving request really came from ReceiptRoller, and the security cautions involved.

Because a Webhook endpoint is a public URL, a third party could in principle send a forged POST. To prevent this, ReceiptRoller attaches an HMAC-SHA256-based signature to every Webhook request. The receiving side must always verify the signature before processing. An endpoint that does not verify is defenseless against attacks.

How the signature works

Before sending, ReceiptRoller computes the signature as follows.

  1. Build the string to sign: {timestamp}.{request_body}
  2. Compute HMAC-SHA256 with the Webhook Secret as the key
  3. Hex-encode the result
  4. Attach it to an HTTP header and send

Related headers sent

Header name Contents
X-RR-Signature The HMAC-SHA256 hex string (64 characters)
X-RR-Timestamp The delivery time (Unix seconds)
X-RR-Event-Id The event ID (idempotency key)
X-RR-Signature-Version The signature version (currently v1)

The receiving side's verification procedure

  1. Take X-RR-Timestamp and the raw request body (the received byte string itself, not the object after parsing)
  2. Build the string to sign {timestamp}.{body}
  3. Compute HMAC-SHA256 with the Webhook Secret you hold and hex-encode it
  4. Compare the computed result against X-RR-Signature with a constant-time comparison (defense against timing attacks)
  5. Check that the timestamp is within ±5 minutes of the current time (defense against replay attacks)

Sample code

Node.js (Express)

const crypto = require("crypto");
const express = require("express");
const app = express();

const SECRET = process.env.RR_WEBHOOK_SECRET;
const TOLERANCE_SEC = 300; // 5 minutes

// Important: use express.raw to keep the raw body
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.header("X-RR-Signature");
  const ts  = req.header("X-RR-Timestamp");
  const body = req.body; // Buffer

  // Timestamp verification
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(ts, 10)) > TOLERANCE_SEC) {
    return res.status(401).send("timestamp out of tolerance");
  }

  // Signature verification
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}.${body.toString("utf8")}`)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send("invalid signature");
  }

  // Verification OK: process the event
  const event = JSON.parse(body.toString("utf8"));
  // ... idempotency check, business processing ...
  res.status(200).send("ok");
});

Python (Flask)

import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["RR_WEBHOOK_SECRET"].encode()
TOLERANCE_SEC = 300

@app.route("/webhook", methods=["POST"])
def webhook():
    sig = request.headers.get("X-RR-Signature", "")
    ts  = request.headers.get("X-RR-Timestamp", "")
    body = request.get_data()  # raw byte string

    # Timestamp verification
    if abs(int(time.time()) - int(ts)) > TOLERANCE_SEC:
        abort(401)

    # Signature verification
    payload = f"{ts}.".encode() + body
    expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)

    # Verification OK: process the event
    event = request.get_json()
    # ... idempotency check, business processing ...
    return "ok", 200

C# (ASP.NET Core)

[HttpPost("/webhook")]
public async Task<IActionResult> Receive()
{
    Request.EnableBuffering();
    using var reader = new StreamReader(Request.Body);
    var body = await reader.ReadToEndAsync();

    var sig = Request.Headers["X-RR-Signature"].ToString();
    var ts  = Request.Headers["X-RR-Timestamp"].ToString();

    // Timestamp verification
    var nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (Math.Abs(nowSec - long.Parse(ts)) > 300) return Unauthorized();

    // Signature verification
    var secret = Encoding.UTF8.GetBytes(_config["RR_WEBHOOK_SECRET"]);
    using var h = new HMACSHA256(secret);
    var expected = Convert.ToHexString(
        h.ComputeHash(Encoding.UTF8.GetBytes($"{ts}.{body}"))
    ).ToLowerInvariant();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(sig),
            Encoding.UTF8.GetBytes(expected)))
        return Unauthorized();

    // Verification OK: process the event
    // ... idempotency check, business processing ...
    return Ok();
}

Common implementation mistakes

Mistake Impact What to do
Computing the signature on the string after JSON parsing Fails every time when whitespace/newlines change Always use the raw byte string
Comparing with a normal == The signature could be guessed via a timing attack Use a constant-time comparison function
Not verifying the timestamp A past valid request could be replayed (replay attack) Implement the ±5-minute tolerance
Hard-coding the secret in code Leaks when the repository is exposed Move it to environment variables or a secret manager
Returning 2xx on verification failure Misleads an attacker into thinking "receive succeeded" Return 401

Managing secrets safely

  • Storage location: environment variables, or a secret management service such as AWS Secrets Manager, Azure Key Vault, GCP Secret Manager
  • Access control: keep it in a state where only production-environment service accounts can read the production secret
  • Rotation: at least once a year, and immediately on suspected leak. See "Rotating and safely storing secrets" for details
  • Environment separation: use separate secrets for production / staging / development
  • No log output: do not output the secret or signature values to error logs or audit logs

Additional network measures (optional)

Signature verification is sufficient, but if you want to operate more strictly, you can also consider the following.

  • IP allow list: add ReceiptRoller's source IP ranges to an allow list (because IP ranges may change without notice, use this as an add-on rather than a substitute for signature verification)
  • WAF: place a WAF in front of the endpoint to block suspicious requests
  • Rate limiting: limit excessive requests from a single IP

Related guides

Published: 2026-04-27 Updated: 2026-07-05
Tags
API (22) OAuth (15) Android (10) iOS (9) Webhook (6) Troubleshooting (5) api (5) App registration (4) POS Integration (4) Reference (4)