Every webhook Rackwave sends is signed with an HMAC-SHA256 signature using your webhook secret. Verifying this signature before processing the payload protects your endpoint from fake events injected by malicious actors. This article explains how the signature works and provides verified code examples in PHP, Python, and Node.js.
Why Signature Verification Is Non-Negotiable
Your webhook endpoint is a public URL. Without signature verification:
- Anyone who discovers your URL can send fake delivery events (e.g. fake
invoice_paidevents to activate services without paying). - Competitors or malicious actors could trigger business logic in your application with fabricated data.
- Replay attacks — resending a previously captured genuine payload — could cause unintended side effects.
How HMAC-SHA256 Signing Works
- When Rackwave sends a webhook, it computes an HMAC-SHA256 hash of the raw request body using your webhook secret as the key.
- The computed hash is prefixed with
sha256=and included in the request header asX-Webhook-Signature. - On your server, you compute the same HMAC-SHA256 hash using the raw body you received and your stored webhook secret.
- You compare your computed hash to the one in the header using a constant-time comparison function (to prevent timing attacks).
- If they match — the payload is genuine. If not — reject it.
The Signature Header
| Header Name | Format | Example |
|---|---|---|
| X-Webhook-Signature | sha256={hex_digest} | sha256=3ecb2f4a8d1c... |
| X-Webhook-Timestamp | Unix timestamp (seconds) | 1717754460 |
Replay Attack Prevention
A replay attack occurs when an attacker captures a genuine webhook payload and resends it later. To prevent this, validate the X-Webhook-Timestamp header:
- Extract the timestamp from
X-Webhook-Timestamp. - Compare it to the current server time.
- If the timestamp is more than 5 minutes in the past (or future), reject the request as a potential replay attack.
Verification Code Examples
PHP
<?php
function verifyWebhookSignature(
string $rawBody,
string $signature,
string $timestamp,
string $secret
): bool {
// Reject if timestamp is older than 5 minutes
if (abs(time() - (int)$timestamp) > 300) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature);
}
// Usage
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '0';
$secret = getenv('WEBHOOK_SECRET');
if (!verifyWebhookSignature($rawBody, $signature, $timestamp, $secret)) {
http_response_code(401);
exit('Unauthorized — invalid or expired webhook signature');
}
Python
import os, hmac, hashlib, time
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', '')
def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
# Reject if timestamp is older than 5 minutes
if abs(time.time() - float(timestamp)) > 300:
return False
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/webhooks/rackwave', methods=['POST'])
def handle_webhook():
raw_body = request.get_data()
signature = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '0')
if not verify_signature(raw_body, signature, timestamp):
return 'Unauthorized', 401
event = request.get_json()
# Process event...
return 'OK', 200
Node.js
const crypto = require('crypto');
const secret = process.env.WEBHOOK_SECRET;
function verifySignature(rawBody, signature, timestamp) {
// Reject if timestamp is older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'utf8'),
Buffer.from(signature, 'utf8')
);
}
// Express middleware (raw body required)
app.post('/webhooks/rackwave', express.raw({ type: '*/*' }), (req, res) => {
const signature = req.headers['x-webhook-signature'] || '';
const timestamp = req.headers['x-webhook-timestamp'] || '0';
if (!verifySignature(req.body, signature, timestamp)) {
return res.status(401).send('Unauthorized');
}
res.status(200).json({ received: true });
const event = JSON.parse(req.body);
handleEvent(event); // async processing
});
Common Signature Verification Failures
| Symptom | Likely Cause | Fix |
|---|---|---|
| Signature never matches | Using the wrong webhook secret, or hashing parsed JSON instead of raw body | Confirm the secret from the platform dashboard; use raw request body bytes |
| Signature matches on test but not on live events | Framework is body-parsing middleware consuming the raw stream before your handler | Use express.raw() not express.json(); or buffer raw bytes before JSON parse |
| Timestamp rejection errors | Server clock is drifted from UTC | Sync server time with an NTP server; consider extending tolerance to 10 minutes temporarily for debugging |
| Signature header not found | Reverse proxy or load balancer stripping custom headers | Configure nginx/Apache/ALB to forward the X-Webhook-* headers unchanged |
Where to Find Your Webhook Secret
- MigoSMTP: Dashboard → Developer → Webhooks → click the webhook row → copy the Secret field.
- Telnxo: Dashboard → Developer → Webhooks → click the webhook row → copy the Secret field.
- Rackwave Portal (subscription events): My Account → Webhooks → copy the platform-specific secret.
Each webhook registration has its own independent secret. If you have multiple webhooks registered, each uses its own secret for signing.