Signature Verification
Every webhook delivery includes an HMAC-SHA256 signature so you can verify that the request genuinely came from Invoice Maker and hasn't been tampered with.
How it works
- Invoice Maker creates a signed payload string:
{timestamp}.{JSON body} - Signs it with your endpoint's signing secret using HMAC-SHA256
- Sends the hex-encoded signature in the
X-Webhook-Signatureheader - Your server reconstructs the same string and compares signatures
Headers used
| Header | Purpose |
|---|---|
X-Webhook-Signature | HMAC-SHA256 hex digest |
X-Webhook-Timestamp | Unix timestamp (seconds) used in the signed payload |
Verification steps
- Extract the
X-Webhook-TimestampandX-Webhook-Signatureheaders - Build the signed payload string:
${timestamp}.${rawBody} - Compute HMAC-SHA256 of that string using your signing secret
- Compare the result with the signature header using a timing-safe comparison
- Optionally, reject requests where the timestamp is too old (recommended: 5 minutes)
Code examples
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(req, signingSecret) {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
if (!signature || !timestamp) {
return false;
}
// Reject requests older than 5 minutes (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
return false;
}
// Reconstruct the signed payload
const rawBody = JSON.stringify(req.body);
const signedPayload = `${timestamp}.${rawBody}`;
// Compute expected signature
const expected = crypto
.createHmac('sha256', signingSecret)
.update(signedPayload)
.digest('hex');
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express middleware
app.post('/webhooks', (req, res) => {
const SIGNING_SECRET = process.env.WEBHOOK_SIGNING_SECRET;
if (!verifyWebhookSignature(req, SIGNING_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Signature verified — process the event
const event = req.body;
console.log(`Received ${event.type}`);
res.status(200).send('OK');
});
Python
import hmac
import hashlib
import time
import json
def verify_webhook_signature(payload_body, headers, signing_secret):
signature = headers.get('X-Webhook-Signature')
timestamp = headers.get('X-Webhook-Timestamp')
if not signature or not timestamp:
return False
# Reject requests older than 5 minutes
age = int(time.time()) - int(timestamp)
if age > 300:
return False
# Reconstruct the signed payload
signed_payload = f"{timestamp}.{payload_body}"
# Compute expected signature
expected = hmac.new(
signing_secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(signature, expected)
PHP
function verifyWebhookSignature(
string $payload,
array $headers,
string $signingSecret
): bool {
$signature = $headers['X-Webhook-Signature'] ?? '';
$timestamp = $headers['X-Webhook-Timestamp'] ?? '';
if (empty($signature) || empty($timestamp)) {
return false;
}
// Reject requests older than 5 minutes
if (time() - intval($timestamp) > 300) {
return false;
}
// Reconstruct the signed payload
$signedPayload = "{$timestamp}.{$payload}";
// Compute expected signature
$expected = hash_hmac('sha256', $signedPayload, $signingSecret);
// Timing-safe comparison
return hash_equals($expected, $signature);
}
// Usage
$payload = file_get_contents('php://input');
$headers = getallheaders();
$secret = getenv('WEBHOOK_SIGNING_SECRET');
if (!verifyWebhookSignature($payload, $headers, $secret)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
// Process the event
$event = json_decode($payload, true);
Important notes
Use the raw body
The signature is computed over the raw JSON string, not a parsed-and-re-serialized version. If your framework parses the body before your handler runs, make sure you have access to the original string.
Express.js tip: Use express.raw() or express.json() with verify option:
app.use('/webhooks', express.json({
verify: (req, buf) => {
req.rawBody = buf.toString();
}
}));
Timing-safe comparison
Always use a constant-time comparison function (crypto.timingSafeEqual, hmac.compare_digest, hash_equals). Standard string equality (===, ==) is vulnerable to timing attacks.
Replay protection
Check the X-Webhook-Timestamp header and reject events older than 5 minutes. This prevents attackers from replaying captured webhook deliveries.
Rotating secrets
If you rotate your signing secret (via Settings > Webhooks > Rotate Secret), the old secret stops working immediately. Update your server with the new secret before rotating, or accept a brief window of failed deliveries.
Always verify webhook signatures in production. Without verification, anyone who discovers your endpoint URL could send fake events to your server.