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 / Express / Koa

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, receivedSignature] = signatureHeader.split('=');

    // Verify algorithm is sha256
    if (algorithm !== 'sha256') {
      return false;
    }

    // Generate expected signature
    const expectedSignature = generateWebhookSignature(payload, secret);

    // Compare using constant-time comparison
    const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
    const receivedBuffer = Buffer.from(receivedSignature, 'utf8');

    if (expectedBuffer.length !== receivedBuffer.length) {
      return false;
    }

    return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
  } catch (error) {
    return false;
  }
}

/**
 * Generate webhook signature
 */
export function generateWebhookSignature(payload: string, secret: string): string {
  const hmac = crypto.createHmac('sha256', secret).update(payload);
  return `sha256=${hmac.digest('hex')}`;
}

Using with Express

import express from 'express';
import { verifyWebhookSignature } from './webhook-utils';

app.post('/webhooks/events', express.json(), (req, res) => {
  const signatureHeader = req.headers['x-webhook-signature'];
  const payload = JSON.stringify(req.body);

  const isValid = verifyWebhookSignature(payload, signatureHeader, process.env.WEBHOOK_SECRET);

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid webhook signature' });
  }

  // Process the webhook
  const event = req.body;
  const timestamp = req.headers['x-webhook-timestamp'];
  const attempt = req.headers['x-webhook-delivery-attempt'];

  console.log('Webhook received:', {
    type: event.type,
    id: event.id,
    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: requestBody.event,
  id: requestBody.event_id,
  timestamp: requestBody.timestamp,
  sellerId: requestBody.seller_id,
  // Do NOT log: signature, secret, or sensitive data
});

Handle errors gracefully

router.post('/webhooks/events', async ctx => {
  try {
    const signatureHeader = ctx.headers['x-webhook-signature'];

    // Verify signature exists
    if (!signatureHeader) {
      ctx.status = 400;
      ctx.body = { error: 'Missing webhook signature' };
      return;
    }

    const payload = JSON.stringify(ctx.request.body);
    const isValid = verifyWebhookSignature(payload, signatureHeader, process.env.WEBHOOK_SECRET);

    if (!isValid) {
      ctx.status = 401;
      ctx.body = { error: 'Invalid webhook signature' };
      return;
    }

    // Process webhook...
    ctx.status = 200;
    ctx.body = { success: true };
  } catch (error) {
    console.error('Webhook processing error:', error);
    ctx.status = 500;
    ctx.body = { error: 'Internal server error' };
  }
});

Idempotency

Webhook deliveries may be retried. Ensure your webhook handler is idempotent by:

  • Storing processed webhook IDs
  • Checking if a webhook has already been processed before handling it
const processedWebhooks = new Set();

router.post('/webhooks/events', async ctx => {
  // ... signature verification ...

  const event = ctx.request.body;

  // Check if we've already processed this webhook
  if (processedWebhooks.has(event.id)) {
    ctx.status = 200;
    ctx.body = { success: true };
    return;
  }

  // Process the webhook
  // ... your logic here ...

  processedWebhooks.add(event.id);
  ctx.status = 200;
  ctx.body = { success: true };
});

Common issues

"Invalid webhook signature"

Cause 1: Payload is not stringified correctly

  • Ensure you're using JSON.stringify() on the raw request body
  • Don't parse and re-stringify - use the exact bytes received

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 click on the Test button. This sends a request with dummy data to your webhook and can be used to verify and test your webhook endpoint.

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: