Signature verification and 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-Signatureheader - 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-Signaturevalue using a constant-time comparison - If they differ, return
401or similar and stop. (Anything other than2xxis retried, but the result will not change while the signature does not) - If they match, check
X-RR-Deliveryfor 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'sid) as processed and do not process the same value again. This is the same mechanism that drops retried duplicates - The body's
timestampis covered by the signature, so you can use it to reject requests that are too old. Retries keep the first attempt'stimestampand 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 JSON | Never matches | Use the received bytes as they are |
Comparing without sha256= | Never matches | Prefix your result with sha256=, or strip it from the header before comparing |
| Comparing upper-case hex or Base64 | Never matches | Use lower-case hex |
| Using the client secret as the key | Never matches | Use the secret from the webhook's registration |
Comparing with == | A timing attack may guess the signature | Use a constant-time comparison |
Returning 2xx when verification fails | A forged request looks accepted | Return 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
-
Receive new sales by webhook and post them to SlackRegister a receipt.created webhook, verify its signature and drop duplicates in Node.js, and post each new sale to a Slack channel.
-
Designing resends, ordering, and idempotencyWhat counts as success, the retry rules (1 min, 10 min, 1 h, four attempts), ordering and lost events, and dropping duplicates by X-RR-Delivery.
-
How to register a WebhookRegister a webhook in the developer portal or through the API, choose events that are actually sent, run a test send, and change, pause or delete a webhook.
-
Webhook is not being deliveredWhen a webhook does not arrive: narrow it down with a test send and the delivery log, then check the active flag, subscribed events, connection and signature.
-
Developer Help IndexThe index of ReceiptRoller developer help. Covers the developer application, app registration, OAuth authentication and scopes, implementation guides (wallet apps, store-facing Webhooks, the Survey API), guides by data domain, operations and security, the community, and troubleshooting.