Skip to main content

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

  1. Invoice Maker creates a signed payload string: {timestamp}.{JSON body}
  2. Signs it with your endpoint's signing secret using HMAC-SHA256
  3. Sends the hex-encoded signature in the X-Webhook-Signature header
  4. Your server reconstructs the same string and compares signatures

Headers used

HeaderPurpose
X-Webhook-SignatureHMAC-SHA256 hex digest
X-Webhook-TimestampUnix timestamp (seconds) used in the signed payload

Verification steps

  1. Extract the X-Webhook-Timestamp and X-Webhook-Signature headers
  2. Build the signed payload string: ${timestamp}.${rawBody}
  3. Compute HMAC-SHA256 of that string using your signing secret
  4. Compare the result with the signature header using a timing-safe comparison
  5. 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.

Never skip verification

Always verify webhook signatures in production. Without verification, anyone who discovers your endpoint URL could send fake events to your server.