Signature verification and security

Webhook Signature verification HMAC X-RR-Signature Security

A webhook's receiving URL is public, so anyone can send it a forged request. ReceiptRoller signs every delivery with the webhook's secret, and your server should verify that signature before it does anything else. This article explains how the signature is computed, gives verification code in three languages, and shows how to replace a secret.

How the signature works

  • The signature is in the X-RR-Signature header
  • Its value is sha256= followed by the HMAC-SHA256 of the request body's bytes exactly as sent (UTF-8), keyed with the webhook's secret (UTF-8), as a lower-case hex string (64 characters)
  • Only the body is signed. There is no timestamp header, and no header value is part of the signature
  • A retry of the same event carries the same body and the same signature as the first attempt
X-RR-Signature: sha256=5d41402abc4b2a76b9719d911017c592... (64 characters)

A delivery carries three headers of its own: X-RR-Signature, X-RR-Event (the event name) and X-RR-Delivery (the event ID).

Verifying on your side

  • Take the body as the bytes you received, before parsing it as JSON. Parsing and re-serialising changes whitespace and key order, and the signature will not match
  • Compute the HMAC-SHA256 with your stored secret as the key, write it as lower-case hex, and put sha256= in front
  • Compare it with the X-RR-Signature value using a constant-time comparison
  • If they differ, return 401 or similar and stop. (Anything other than 2xx is retried, but the result will not change while the signature does not)
  • If they match, check X-RR-Delivery for duplicates, then process

Sample code

Node.js (Express)

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.RR_WEBHOOK_SECRET;
const app = express();

function hasValidSignature(rawBody, header) {
  if (!header) return false;
  const expected = "sha256=" +
    crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw hands you the unparsed body as a Buffer
app.post("/rr-webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!hasValidSignature(req.body, req.get("X-RR-Signature"))) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // check X-RR-Delivery for duplicates, then process
  res.status(200).end();
});

Python (Flask)

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

app = Flask(__name__)
SECRET = os.environ["RR_WEBHOOK_SECRET"].encode("utf-8")

@app.post("/rr-webhook")
def rr_webhook():
    body = request.get_data()  # the unparsed bytes
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-RR-Signature", "")):
        abort(401)
    event = request.get_json()
    # check X-RR-Delivery for duplicates, then process
    return "", 200

C# (ASP.NET Core)

[HttpPost("/rr-webhook")]
public async Task<IActionResult> Receive()
{
    using var buffer = new MemoryStream();
    await Request.Body.CopyToAsync(buffer);
    var body = buffer.ToArray(); // the unparsed bytes

    var secret = Encoding.UTF8.GetBytes(_config["RR_WEBHOOK_SECRET"]!);
    var expected = "sha256=" + Convert.ToHexStringLower(HMACSHA256.HashData(secret, body));
    var received = Request.Headers["X-RR-Signature"].ToString();

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

    // check X-RR-Delivery for duplicates, then process
    return Ok();
}

Guarding against replays

Because only the body is signed, someone who resends a genuine request unchanged will pass signature verification. Guard against that like this.

  • Record X-RR-Delivery (the body's id) as processed and do not process the same value again. This is the same mechanism that drops retried duplicates
  • The body's timestamp is covered by the signature, so you can use it to reject requests that are too old. Retries keep the first attempt's timestamp and can arrive up to about 71 minutes later, so do not make the window shorter than about two hours

Issuing and replacing the secret

  • The secret is fixed when you register the webhook. Through the API you give 16 to 64 characters or omit it to get a 32-character alphanumeric one; in the developer portal it is always generated
  • The secret is shown once, at registration. There is no way to show it again or to regenerate it
  • To replace a secret, register a webhook for the same URL with a new secret, let your server accept either the old or the new secret for a while, then delete the old webhook. While both exist, each event arrives from both webhooks, but with the same X-RR-Delivery, so duplicate dropping processes it once
  • If you suspect a leak, register a new webhook and delete the old one straight away

Webhooks registered earlier

Webhooks registered before the fix of 23 October 2026 are signed with the app's client ID, not with a secret. If verification with your secret never matches, register the webhook again; newly registered webhooks are signed with their secret.

Common mistakes

Mistake What happens Fix
Computing over re-serialised JSONNever matchesUse the received bytes as they are
Comparing without sha256=Never matchesPrefix your result with sha256=, or strip it from the header before comparing
Comparing upper-case hex or Base64Never matchesUse lower-case hex
Using the client secret as the keyNever matchesUse the secret from the webhook's registration
Comparing with ==A timing attack may guess the signatureUse a constant-time comparison
Returning 2xx when verification failsA forged request looks acceptedReturn 401

Storing the secret

  • Keep it out of code and repositories; use environment variables or a secret manager
  • Register separate webhooks, with separate secrets, for production, staging and development
  • Never write the secret or signature values to logs

Related guides

Published: 2026-04-27 Updated: 2026-10-26