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 was not 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 is included in the X-Rodz-Signature header.
On your end, you perform the same computation. If your computed hash matches the one in the header, two things are proven:
- 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 was not altered after signing. Changing even a single byte of the body would produce a completely different hash.
Without signature verification, your webhook endpoint is essentially a public URL that accepts arbitrary JSON. Anyone who discovers it (or guesses it) can send fake payloads. Imagine a competitor injecting a false “funding round” signal into your pipeline, triggering your sales team to chase a lead that does not exist. Signature verification eliminates this attack vector entirely.
If you have not yet set up your webhook endpoint, 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 have not 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 critical. Signature verification must be performed on the exact bytes Rodz sent, before any JSON parsing or middleware transformation. We will cover how to handle this in detail.
- Basic familiarity with HTTP headers and cryptographic hashing. You do not need to be a security expert, but understanding that a hash function maps arbitrary input to a fixed-length output will help.
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
)
Note that 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 essential for replay attack protection (more on that below).
Here is 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
The single most important step is preserving the raw request body. 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 is the complete Node.js implementation. Three key points to remember: raw body preservation, timestamp validation, and constant-time comparison.
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 do not need to implement it yourself.
Replay Attack Protection
You may have noticed both implementations check the X-Rodz-Timestamp header against the current time. This is replay attack protection. Without it, an attacker who intercepts a legitimate webhook payload could resend it hours, days, or weeks later, and it would still pass signature verification because the body and signature have not changed.
By including the timestamp in the signed payload and rejecting requests older than five minutes, you close this window. Even if an attacker captures a valid request, it becomes useless after 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.
- Do not set it below 60 seconds. Rodz retries failed deliveries, and a very short window could reject legitimate retries.
- Ensure your server clock is synchronized. Use NTP (Network Time Protocol) to keep your system time accurate. Most cloud providers handle this automatically, but if you are running your own server, verify that NTP is configured.
Handling Secret Rotation
At some point, you will need to rotate your webhook secret. Perhaps an employee who had access has left the company, or your security policy mandates periodic rotation. The Rodz API supports 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 vulnerability by always taking the same amount of time regardless of where the strings differ.
Hardcoding the Secret in Source Code
Your webhook secret should live 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. Do not tell the caller whether the signature was wrong, the timestamp was 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 should receive a 200 response. Try modifying the body or signature to confirm that invalid payloads are rejected 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 will not deliver to HTTP)
FAQ
What happens if verification fails on a legitimate Rodz payload?
First, check that your server clock is accurate. Clock drift is the most common cause of false rejections. Second, confirm you are verifying against the raw body, not parsed JSON. Third, ensure you are using the correct secret. If you recently rotated secrets, the old one may have expired. If none of these resolve the issue, check the Rodz API documentation for 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 cannot substitute MD5, SHA-1, or SHA-512. SHA-256 provides strong collision resistance and is the industry 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 is what Rodz signed. In Nginx, ensure proxy_pass_request_headers on; is set (it is on by default).
Should I verify webhooks in development and staging environments?
Yes. Always verify signatures in every environment. 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 will never be your bottleneck. Do not skip it for performance reasons.
How do I debug a signature mismatch?
Temporarily log the following (in a non-production environment): 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 being different 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 should not. 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 available at https://api.rodz.io/docs. For a guided walkthrough of the API surface, see the API reference article.
Wrapping Up
Webhook signature verification is not optional. It is a foundational security measure that 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.
If you are building on the Rodz API, get the verification right from day one. Retrofitting security is always harder than building it in. For the full picture of what the Rodz API offers, explore the complete API reference, and if you need interactive documentation, head to https://api.rodz.io/docs.