Webhook signature
Learn how to verify Checkout Page webhook signatures to ensure authenticity
Webhook security is critical. All webhooks sent by Checkout Page include a signature in the x-webhook-signature header that you should verify to ensure the webhook is authentic and hasn't been tampered with.
Why verify signatures?
Verifying webhook signatures protects your application by:
- Ensuring the webhook came from Checkout Page
- Preventing request forgery and replay attacks
- Detecting if the webhook payload has been modified in transit
How it works
Checkout Page generates a signature using HMAC-SHA256 with your webhook secret. The signature is sent in the x-webhook-signature header with every webhook request in the format sha256=<hex-digest>. You can verify the signature by:
- Taking the raw webhook payload as a string
- Computing an HMAC-SHA256 hash using your webhook secret
- Comparing the computed hash with the signature in the header using constant-time comparison
The signature format is always sha256=<64-character-hex-string>. You must parse out the hex digest portion before comparison.
Important: Always use constant-time comparison (not standard string equality) to prevent timing attacks. This prevents attackers from using the time taken to compare signatures to guess the correct signature character-by-character.
Webhook headers
Every webhook request includes the following headers:
| Header | Format | Description |
|---|---|---|
x-webhook-signature | sha256=<hex> | HMAC-SHA256 signature of the payload |
x-webhook-timestamp | Unix timestamp | Time the webhook was sent |
x-webhook-delivery-attempt | Number | Attempt number (1, 2, 3, etc.) for retries |
Example x-webhook-signature header value:
sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Implementation
Node.js
Here's the recommended function to verify webhook signatures:
import crypto from 'crypto';
/**
* Verify webhook signature
*/
export function verifyWebhookSignature(
payload: string,
signatureHeader: string,
secret: string,
): boolean {
try {
// Parse the signature header format: "sha256=<hex-digest>"
const [algorithm, receivedDigest] = signatureHeader.split('=');
// Verify algorithm is sha256
if (algorithm !== 'sha256' || !receivedDigest) {
return false;
}
// Generate the expected hex digest
const expectedDigest = generateWebhookDigest(payload, secret);
// Compare the two hex digests using constant-time comparison
const expectedBuffer = Buffer.from(expectedDigest, 'utf8');
const receivedBuffer = Buffer.from(receivedDigest, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
} catch (error) {
return false;
}
}
/**
* Generate the HMAC-SHA256 hex digest of the payload (the part of the header after "sha256=")
*/
export function generateWebhookDigest(payload: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}Using with Express
Verify the request body exactly as it arrived, before parsing it. Use express.raw() on the webhook route so req.body holds the raw bytes, and register the route before any app-wide express.json(), which would parse the body first.
import express from 'express';
import { verifyWebhookSignature } from './webhook-utils';
app.post('/webhooks/events', express.raw({ type: 'application/json' }), (req, res) => {
const signatureHeader = req.headers['x-webhook-signature'];
const payload = req.body.toString('utf8');
const isValid = verifyWebhookSignature(payload, signatureHeader, process.env.WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
// Parse only after the signature checks out
const webhook = JSON.parse(payload);
const timestamp = req.headers['x-webhook-timestamp'];
const attempt = req.headers['x-webhook-delivery-attempt'];
console.log('Webhook received:', {
event: webhook.event,
eventId: webhook.eventId,
timestamp,
attempt,
});
res.status(200).json({ success: true });
});Best practices
Store your webhook secret securely
Never hardcode your webhook secret. Always use environment variables:
# .env file
WEBHOOK_SECRET=1234567890abcdefLog webhook events
For debugging, log incoming webhooks (but never log the signature):
console.log('Webhook received', {
event: webhook.event,
eventId: webhook.eventId,
timestamp: webhook.timestamp,
sellerId: webhook.sellerId,
// Do NOT log: signature, secret, or sensitive data
});Handle errors gracefully
app.post('/webhooks/events', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const signatureHeader = req.headers['x-webhook-signature'];
// Verify signature exists
if (!signatureHeader) {
return res.status(400).json({ error: 'Missing webhook signature' });
}
const payload = req.body.toString('utf8');
const isValid = verifyWebhookSignature(payload, signatureHeader, process.env.WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
// Process webhook...
res.status(200).json({ success: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});Idempotency
Webhook deliveries may be retried. Ensure your webhook handler is idempotent by:
- Storing the
eventIdof each webhook you process - Checking if a webhook has already been processed before handling it
const processedWebhooks = new Set();
app.post('/webhooks/events', express.raw({ type: 'application/json' }), (req, res) => {
// ... signature verification ...
const webhook = JSON.parse(req.body.toString('utf8'));
// Check if we've already processed this webhook
if (processedWebhooks.has(webhook.eventId)) {
return res.status(200).json({ success: true });
}
// Process the webhook
// ... your logic here ...
processedWebhooks.add(webhook.eventId);
res.status(200).json({ success: true });
});Common issues
"Invalid webhook signature"
Cause 1: The payload was parsed before verifying
- Verify against the raw request body, exactly as received
- Don't parse the JSON and serialize it again: the result can differ from what was signed, for example in spacing
Cause 2: Secret is incorrect or missing
- Verify
WEBHOOK_SECRETis set in your environment variables - Check that it matches the secret shown in your dashboard
Cause 3: Missing or malformed signature header
- Ensure the
x-webhook-signatureheader is present - Check for typos in the header name
- Verify the signature format is
sha256=<64-char-hex-string>
Testing webhooks
In the Checkout Page dashboard, open the webhook's actions menu and select Send test event. This sends a signed request with dummy data to your webhook, so you can check that your endpoint verifies it.
Rotating your secret
Rotation is immediate: the previous secret stops working and every delivery from then on is signed with the new one, including retries of earlier events.
- In the dashboard, go to Settings > Webhooks, open the webhook's actions menu and select View signing secret.
- Select Rotate secret, then Rotate now to confirm.
- Update your endpoint with the new secret straight away. Deliveries it rejects while it still has the old secret are retried briefly and then not sent again.
- Use Send test event from the same menu to confirm your endpoint accepts the new signature.
Security considerations
- Never skip signature verification in production
- Use HTTPS for all webhook endpoints
- Rotate secrets periodically for enhanced security
- Log failures for monitoring and debugging
- Set timeouts to prevent hanging requests
- Use idempotency keys to handle retried webhooks
Next steps
Explore these webhook-related topics: