When signing is enabled for an endpoint, every request includes an X-Rolla-Signature header. Verifying it lets you confirm the request came from Rolla and was not tampered with in transit.
Signing is optional and enabled per endpoint. If you disable it, requests are sent without the X-Rolla-Signature header. We strongly recommend keeping it on for production.
X-Rolla-Signature: t=1749556805,v1=4f1d2c...e9b7
| Part | Meaning |
|---|
t | Unix timestamp (seconds) when the signature was generated |
v1 | Hex-encoded HMAC-SHA256 signature |
How the signature is computed
Rolla builds a signed string by concatenating the timestamp, a literal ., and the raw request body:
signed_payload = "{t}" + "." + "{raw_request_body}"
signature = HMAC_SHA256(signing_secret, signed_payload) // hex
Verifying
Read the raw body
Verify against the exact raw bytes of the request body, before any JSON parsing or re-serialization.
Recompute the HMAC
Concatenate t, ., and the raw body, then compute HMAC_SHA256 with your endpoint’s signing secret.
Compare in constant time
Compare your computed value against v1 using a constant-time comparison.
(Recommended) Check the timestamp
Reject requests whose t is too old (for example, more than 5 minutes) to mitigate replay.
Node.js (Express)
import crypto from 'crypto';
import express from 'express';
const app = express();
const SIGNING_SECRET = process.env.ROLLA_WEBHOOK_SECRET;
// Capture the raw body so the signature can be verified.
app.use('/webhooks/rolla', express.raw({ type: 'application/json' }));
app.post('/webhooks/rolla', (req, res) => {
const header = req.header('X-Rolla-Signature') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const { t, v1 } = parts;
const rawBody = req.body.toString('utf8');
const expected = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
const valid =
v1 &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!valid) {
return res.status(400).send('Invalid signature');
}
// Optional: reject stale events (replay protection)
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
return res.status(400).send('Stale signature');
}
const event = JSON.parse(rawBody);
// Acknowledge immediately, then process asynchronously.
res.sendStatus(200);
handleEvent(event);
});
Python (Flask)
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = "whsec_..."
@app.post("/webhooks/rolla")
def rolla_webhook():
header = request.headers.get("X-Rolla-Signature", "")
parts = dict(p.split("=") for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
raw_body = request.get_data(as_text=True)
expected = hmac.new(
SIGNING_SECRET.encode(),
f"{t}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
if not v1 or not hmac.compare_digest(v1, expected):
abort(400, "Invalid signature")
if abs(time.time() - int(t)) > 300:
abort(400, "Stale signature")
# Acknowledge, then process.
return "", 200
Always verify against the raw request body. If your framework parses JSON and you re-serialize it, the bytes may differ (key order, whitespace) and the signature will not match.