TL;DR: Every Rodz webhook payload includes an
X-Rodz-Signatureheader containing an HMAC-SHA256 hash of the request body, signed with your webhook secret. Verifying this signature before processing ensures the payload actually came from Rodz and wasn’t tampered with in transit. This guide provides production-ready verification code in Node.js and Python, covers replay attack protection with timestamps, and walks through the most common implementation mistakes.
What Is HMAC-SHA256 and Why Does It Matter for Webhooks?
When you register a webhook endpoint with the Rodz API, you receive a shared secret. Every time Rodz sends a webhook payload to your server, it computes an HMAC (Hash-based Message Authentication Code) using SHA-256 as the underlying hash function, your shared secret as the key, and the raw request body as the message. The resulting hex digest goes into the X-Rodz-Signature header.
On your end, you run the same computation. If your computed hash matches the one in the header, two things are confirmed:
- Authenticity. The payload was sent by someone who knows the shared secret. Since only you and Rodz have it, the request genuinely came from the Rodz platform.
- Integrity. The payload wasn’t altered after signing. Changing even a single byte of the body produces a completely different hash.
Without signature verification, your webhook endpoint is a public URL that accepts arbitrary JSON. Anyone who finds it can send fake payloads. Think about a bad actor injecting a false “funding round” signal into your pipeline and triggering your sales team to chase a lead that doesn’t exist. Signature verification closes that attack vector.
If you haven’t set up your webhook endpoint yet, start with the webhook setup guide before continuing here. For a broader overview of the Rodz API surface, including rate limits and error codes, see the full API reference.
Prerequisites
Before implementing signature verification, make sure you have:
- A working Rodz webhook endpoint that receives and processes payloads. Follow the webhook setup guide if you haven’t done this yet.
- Your webhook signing secret. This is generated when you register your webhook URL via the Rodz API or dashboard. Store it in an environment variable, never in source code.
- Node.js 18+ or Python 3.8+, depending on which implementation you plan to follow. Both examples below are self-contained.
- Access to the raw request body. This is the critical one. Signature verification must run on the exact bytes Rodz sent, before any JSON parsing or middleware transformation. More on how to handle this below.
- Basic familiarity with HTTP headers and cryptographic hashing. You don’t need a security background, but understanding that a hash function maps arbitrary input to a fixed-length output will help you follow the logic.
How the Rodz Signature Scheme Works
Every webhook request from Rodz includes three relevant headers:
| Header | Description |
|---|---|
X-Rodz-Signature | HMAC-SHA256 hex digest of the raw request body, keyed with your webhook secret |
X-Rodz-Timestamp | Unix timestamp (seconds) indicating when Rodz generated the payload |
Content-Type | Always application/json |
The signature is computed as follows:
HMAC-SHA256(
key = your_webhook_secret,
message = timestamp + "." + raw_request_body
)
The timestamp is prepended to the body with a dot separator before hashing. This binds the signature to a specific moment in time, which is what makes replay attack protection work (more on that below).
Here’s what a typical request looks like:
POST /webhooks/rodz HTTP/1.1
Host: your-server.com
Content-Type: application/json
X-Rodz-Signature: a3f7c2e91b4d5a8f0e6c1d2b3a4f5e6d7c8b9a0f1e2d3c4b5a6f7e8d9c0b1a2
X-Rodz-Timestamp: 1710345600
{"event":"signal.fired","signal_type":"funding_round","company":{"name":"Acme Corp","siren":"123456789"},"amount":5000000,"currency":"EUR","detected_at":"2026-03-13T14:00:00Z"}
Step-by-Step: Implementing Verification in Node.js
Step 1: Capture the Raw Body
Preserving the raw request body is the most important step. If you use Express with express.json(), the middleware parses the body and discards the original bytes. Any whitespace reformatting or key reordering during parsing will change the hash.
Express provides a verify callback that gives you access to the raw buffer before parsing:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Capture raw body for signature verification
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
}),
);
This stores the raw Buffer on req.rawBody while still parsing the JSON into req.body for convenient access later.
Step 2: Verify the Signature
Create a middleware function that checks the signature before your route handler runs:
const WEBHOOK_SECRET = process.env.RODZ_WEBHOOK_SECRET;
const MAX_TIMESTAMP_AGE_SECONDS = 300; // 5 minutes
function verifyRodzSignature(req, res, next) {
const signature = req.headers['x-rodz-signature'];
const timestamp = req.headers['x-rodz-timestamp'];
// Reject requests missing required headers
if (!signature || !timestamp) {
return res.status(401).json({ error: 'Missing signature headers' });
}
// Protect against replay attacks
const currentTime = Math.floor(Date.now() / 1000);
const requestTime = parseInt(timestamp, 10);
if (Math.abs(currentTime - requestTime) > MAX_TIMESTAMP_AGE_SECONDS) {
return res.status(401).json({ error: 'Timestamp too old or too far in the future' });
}
// Compute expected signature
const signedPayload = `${timestamp}.${req.rawBody}`;
const expectedSignature = crypto.createHmac('sha256', WEBHOOK_SECRET).update(signedPayload).digest('hex');
// Constant-time comparison to prevent timing attacks
const expected = Buffer.from(expectedSignature, 'hex');
const received = Buffer.from(signature, 'hex');
if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
}
Step 3: Apply the Middleware to Your Route
app.post('/webhooks/rodz', verifyRodzSignature, (req, res) => {
const event = req.body;
console.log('Verified signal:', event.signal_type, event.company.name);
// Safe to process: the payload is authentic and untampered
// Store in database, notify team, trigger automation...
res.status(200).json({ received: true });
});
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});
That’s the complete Node.js implementation. Raw body preservation, timestamp validation, constant-time comparison: get all three right and you’re done.
Step-by-Step: Implementing Verification in Python
Step 1: Set Up a Flask Endpoint with Raw Body Access
Flask makes raw body access straightforward through request.get_data():
import hmac
import hashlib
import time
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['RODZ_WEBHOOK_SECRET']
MAX_TIMESTAMP_AGE_SECONDS = 300 # 5 minutes
Step 2: Build the Verification Function
def verify_rodz_signature(request):
"""Verify the HMAC-SHA256 signature of a Rodz webhook payload.
Returns True if the signature is valid, False otherwise.
"""
signature = request.headers.get('X-Rodz-Signature')
timestamp = request.headers.get('X-Rodz-Timestamp')
if not signature or not timestamp:
return False
# Check timestamp freshness
current_time = int(time.time())
request_time = int(timestamp)
if abs(current_time - request_time) > MAX_TIMESTAMP_AGE_SECONDS:
return False
# Compute expected signature
raw_body = request.get_data(as_text=True)
signed_payload = f"{timestamp}.{raw_body}"
expected_signature = hmac.new(
key=WEBHOOK_SECRET.encode('utf-8'),
msg=signed_payload.encode('utf-8'),
digestmod=hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected_signature, signature)
Step 3: Wire It Into Your Route
@app.route('/webhooks/rodz', methods=['POST'])
def handle_rodz_webhook():
if not verify_rodz_signature(request):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json()
print(f"Verified signal: {event['signal_type']} - {event['company']['name']}")
# Safe to process the payload
# Store in database, notify team, trigger automation...
return jsonify({'received': True}), 200
if __name__ == '__main__':
app.run(port=3000)
Python’s hmac.compare_digest handles constant-time comparison natively, so you don’t need to roll your own.
Replay Attack Protection
Both implementations check the X-Rodz-Timestamp header against the current time. That’s replay attack protection. Without it, an attacker who intercepts a legitimate webhook payload could resend it hours or days later and it would still pass signature verification, because the body and signature haven’t changed.
Including the timestamp in the signed payload and rejecting requests older than five minutes closes that window. A captured valid request becomes useless once the timestamp expires.
A few notes on the time window:
- Five minutes is a reasonable default. It accounts for minor clock drift between Rodz servers and yours, plus brief network delays during retries.
- Don’t set it below 60 seconds. Rodz retries failed deliveries, and a very short window could reject legitimate retries.
- Keep your server clock synchronized. Use NTP (Network Time Protocol) to stay accurate. Most cloud providers handle this automatically, but if you’re running your own server, verify that NTP is configured.
Handling Secret Rotation
At some point you’ll need to rotate your webhook secret. Maybe an employee with access has left, or your security policy mandates periodic rotation. The Rodz API handles this gracefully.
When you rotate your secret through the dashboard or API, Rodz briefly signs payloads with both the old and new secret during a transition period (typically 24 hours). To handle this:
const WEBHOOK_SECRETS = [process.env.RODZ_WEBHOOK_SECRET_CURRENT, process.env.RODZ_WEBHOOK_SECRET_PREVIOUS].filter(
Boolean,
);
function verifyWithMultipleSecrets(req) {
const signature = req.headers['x-rodz-signature'];
const timestamp = req.headers['x-rodz-timestamp'];
const signedPayload = `${timestamp}.${req.rawBody}`;
for (const secret of WEBHOOK_SECRETS) {
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(signature, 'hex');
if (expectedBuf.length === receivedBuf.length && crypto.timingSafeEqual(expectedBuf, receivedBuf)) {
return true;
}
}
return false;
}
Store both secrets as environment variables. Once the transition period ends, remove the old secret and update your configuration.
Common Mistakes to Avoid
Parsing the Body Before Verification
This is the most frequent mistake. JSON parsers can reorder keys, normalize Unicode, or adjust whitespace. Even a single changed byte produces a completely different HMAC hash. Always verify the signature against the raw bytes received over the wire, not the parsed-and-re-serialized JSON.
Using Simple String Comparison
A naive === or == comparison between the expected and received signatures is vulnerable to timing attacks. An attacker can send thousands of requests with slightly different signatures and measure response times to deduce the correct hash byte by byte. Constant-time comparison functions (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python) eliminate this by always taking the same amount of time regardless of where the strings differ.
Hardcoding the Secret in Source Code
Your webhook secret belongs in an environment variable or a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.). Never commit it to your repository. Even private repositories can be compromised, and secrets in git history are notoriously difficult to fully remove.
Ignoring the Timestamp
Skipping timestamp validation means your endpoint will accept replayed payloads indefinitely. Always check the X-Rodz-Timestamp header and reject requests outside your acceptable time window.
Logging the Full Payload Before Verification
If you log the raw payload before checking the signature, an attacker can flood your logs with garbage data by sending fake webhook requests. This can obscure legitimate entries, fill your disk, or trigger log-based alerts. Verify first, log second.
Returning Detailed Error Messages
When verification fails, return a generic 401 response. Don’t tell the caller whether the signature was wrong, the timestamp expired, or a header was missing. Detailed errors help attackers refine their approach.
Testing Your Implementation
Manual Testing with cURL
You can generate a test signature locally and send it with cURL to verify your implementation:
# Set your variables
SECRET="your_webhook_secret"
TIMESTAMP=$(date +%s)
BODY='{"event":"signal.fired","signal_type":"funding_round","company":{"name":"Test Corp","siren":"999999999"}}'
# Compute the signature
SIGNATURE=$(echo -n "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "${SECRET}" | awk '{print $2}')
# Send the request
curl -X POST http://localhost:3000/webhooks/rodz \
-H "Content-Type: application/json" \
-H "X-Rodz-Signature: ${SIGNATURE}" \
-H "X-Rodz-Timestamp: ${TIMESTAMP}" \
-d "${BODY}"
If your implementation is correct, you’ll get a 200 response. Try modifying the body or signature to confirm that invalid payloads come back with 401.
Automated Testing
For CI pipelines, write unit tests that cover four scenarios:
- Valid signature and timestamp. Should return 200.
- Invalid signature. Should return 401.
- Expired timestamp. Should return 401.
- Missing headers. Should return 401.
const crypto = require('crypto');
function generateTestPayload(secret, body, timestampOffset = 0) {
const timestamp = Math.floor(Date.now() / 1000) + timestampOffset;
const signedPayload = `${timestamp}.${body}`;
const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
return { signature, timestamp: String(timestamp), body };
}
// Use this helper in your test suite to generate
// valid and invalid payloads for each scenario.
Production Checklist
Before deploying your webhook verification to production, run through this list:
- Webhook secret stored in environment variable or secrets manager
- Raw body captured before JSON parsing
- HMAC-SHA256 computed with timestamp prepended (
timestamp.body) - Constant-time comparison used for signature matching
- Timestamp validation with a 5-minute tolerance window
- Server clock synchronized via NTP
- Secret rotation strategy documented and tested
- Generic 401 responses returned on verification failure
- Logging occurs only after successful verification
- Automated tests cover valid, invalid, expired, and missing-header scenarios
- HTTPS enforced on the webhook endpoint (Rodz won’t deliver to HTTP)
FAQ
What happens if verification fails on a legitimate Rodz payload?
Start with your server clock. Clock drift is the most common cause of false rejections. Then confirm you’re verifying against the raw body, not parsed JSON. Then check that you’re using the correct secret. If you recently rotated secrets, the old one may have expired. If none of these fix it, check the Rodz API documentation for any updates to the signing scheme.
Can I use a different hash algorithm instead of SHA-256?
No. The Rodz webhook signing scheme uses HMAC-SHA256 exclusively. You can’t substitute MD5, SHA-1, or SHA-512. SHA-256 provides strong collision resistance and is the standard for webhook signatures, used by Stripe, GitHub, Shopify, and most other platforms with webhook support.
How do I handle webhooks behind a reverse proxy or load balancer?
Reverse proxies and load balancers can modify request headers or body encoding. Make sure your proxy forwards the original X-Rodz-Signature and X-Rodz-Timestamp headers unmodified. If your proxy decompresses gzip-encoded bodies, verify the signature against the decompressed body, since that’s what Rodz signed. In Nginx, confirm proxy_pass_request_headers on; is set (it’s on by default).
Should I verify webhooks in development and staging environments?
Yes, always. Skipping verification in development creates a false sense of security and means bugs in your verification logic will only surface in production. Use a separate webhook secret for each environment to maintain isolation.
What is the performance impact of HMAC-SHA256 verification?
Negligible. Computing an HMAC-SHA256 hash of a typical webhook payload (a few kilobytes) takes microseconds on modern hardware. Even at high webhook volumes, verification won’t be your bottleneck. Don’t skip it for performance reasons.
How do I debug a signature mismatch?
In a non-production environment, temporarily log the following: the raw body bytes, the timestamp value, the signed payload string you computed, the expected signature, and the received signature. Compare them character by character. The most common culprit is the raw body differing from what you expect, usually because middleware modified it before your verification code ran.
Can I use the same webhook secret for multiple endpoints?
You can, but you shouldn’t. If one endpoint is compromised, every endpoint sharing that secret is compromised too. Register each webhook URL with its own secret. The Rodz API generates a unique secret per webhook registration, so this is the default behavior.
Where can I find the full Rodz API documentation?
The complete API reference, including webhook registration endpoints and all available signal types, is at https://api.rodz.io/docs. For a guided walkthrough of the API surface, see the API reference article.
Wrapping Up
Webhook signature verification isn’t optional. It protects your pipeline from forged payloads, replay attacks, and data tampering. The implementation is straightforward: capture the raw body, compute the HMAC, compare in constant time, validate the timestamp. A few dozen lines of code stand between your system and an open door.
Build the verification in from day one. Retrofitting security is always harder. For the full picture of what the Rodz API offers, see the complete API reference, and if you need interactive documentation, head to https://api.rodz.io/docs.