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:

  1. Taking the raw webhook payload as a string
  2. Computing an HMAC-SHA256 hash using your webhook secret
  3. 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:

HeaderFormatDescription
x-webhook-signaturesha256=<hex>HMAC-SHA256 signature of the payload
x-webhook-timestampUnix timestampTime the webhook was sent
x-webhook-delivery-attemptNumberAttempt number (1, 2, 3, etc.) for retries

Example x-webhook-signature header value:

sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Implementation

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=1234567890abcdef

Log 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 eventId of 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_SECRET is 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-signature header 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.

  1. In the dashboard, go to Settings > Webhooks, open the webhook's actions menu and select View signing secret.
  2. Select Rotate secret, then Rotate now to confirm.
  3. 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.
  4. 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:

On this page