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.
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.
- Build the string to sign:
{timestamp}.{request_body} - Compute HMAC-SHA256 with the Webhook Secret as the key
- Hex-encode the result
- 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
- Take
X-RR-Timestampand the raw request body (the received byte string itself, not the object after parsing) - Build the string to sign
{timestamp}.{body} - Compute HMAC-SHA256 with the Webhook Secret you hold and hex-encode it
- Compare the computed result against
X-RR-Signaturewith a constant-time comparison (defense against timing attacks) - 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
Category
Tags
API (22)
OAuth (15)
Android (10)
iOS (9)
Webhook (6)
Troubleshooting (5)
api (5)
App registration (4)
POS Integration (4)
Reference (4)
Related articles
-
Designing resends, ordering, and idempotencyExplains ReceiptRoller's Webhook resend policy, why delivery order is not guaranteed, implementing idempotency with event_id, handling dead letters, and common anti-patterns.
-
How to register a WebhookExplains how to register a Webhook endpoint in the ReceiptRoller developer portal, how to choose subscribed events, test delivery, using multiple endpoints, and how to delete or pause an endpoint.
-
Webhook is not being deliveredHow to isolate the cause when a Webhook does not reach your receiving endpoint. Walks through checking, in order, the endpoint settings, subscribed events, reachability, signature-verification failures, and firewalls.
-
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.
-
Monitoring and handling failuresExplains how to read the ReceiptRoller Webhook delivery history, the metrics to monitor and how to design alerts, redelivering dead letters, and common failure patterns and recovery procedures.