Skip to main content
Learn how to verify webhook signatures to ensure requests are authentic and from Parchment.

Why Verify Signatures?

Webhook signature verification ensures that:
  • Webhooks are sent by Parchment, not a malicious actor
  • The payload hasn’t been tampered with
  • The webhook isn’t a replay attack (using timestamps)
Never process webhooks without verifying the signature. This could allow attackers to send fake events to your system.

How It Works

Parchment signs every webhook using HMAC SHA-256:
  1. Creates a signed payload: timestamp.json_body
  2. Computes HMAC SHA-256 signature using your webhook secret
  3. Sends the signature in the X-Webhook-Signature header
  4. Your application verifies the signature matches

Signature Header Format

The X-Webhook-Signature header contains:
t=1702465200,v1=5a2c7f3e8b9d4a1c6e5f8b2d9a4c7e1f3a8b5d2c9e6f1a4b7c3e8d5f2a9c6e3f
Where:
  • t = Unix timestamp (seconds) when the webhook was sent
  • v1 = HMAC SHA-256 signature (hex-encoded)

Step-by-Step Verification

Step 1: Extract the Signature Header

Get the X-Webhook-Signature header from the request.

Step 2: Parse Timestamp and Signature

Split the header on commas and extract the timestamp (t) and signature (v1).

Step 3: Check Timestamp Tolerance

Verify the timestamp is within 5 minutes of the current time to prevent replay attacks.

Step 4: Compute Expected Signature

Create the signed payload: timestamp.raw_json_body Compute HMAC SHA-256 using your webhook secret.

Step 5: Compare Signatures

Use timing-safe comparison to compare the signatures.

Implementation Examples

import express from 'express';
import crypto from 'crypto';

const app = express();

// Retrieve from your secrets manager (e.g. AWS Secrets Manager, HashiCorp Vault)
let WEBHOOK_SECRET: string;

async function start() {
  WEBHOOK_SECRET = await getSecret('parchment-webhook-secret');
  app.listen(3000);
}

// IMPORTANT: Use raw body parser for webhook routes
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-webhook-signature'] as string;
    const payload = req.body.toString('utf8');

    // Verify signature
    const result = verifyWebhook(payload, signature, WEBHOOK_SECRET);

    if (!result.valid) {
      console.error('Webhook verification failed:', result.error);
      return res.status(400).json({ error: 'Invalid signature' });
    }

    // Parse and process the event
    const event = JSON.parse(payload);
    console.log('Received event:', event.event_type);

    // Process the event...

    res.status(200).json({ received: true });
  }
);

function verifyWebhook(
  payload: string,
  signatureHeader: string,
  secret: string,
  toleranceSeconds: number = 300
): { valid: boolean; error?: string } {
  const parts = signatureHeader.split(',');
  let timestamp: number | null = null;
  let signature: string | null = null;

  for (const part of parts) {
    const [key, value] = part.split('=');
    if (key === 't') timestamp = parseInt(value, 10);
    else if (key === 'v1') signature = value;
  }

  if (!timestamp || !signature || isNaN(timestamp)) {
    return { valid: false, error: 'Invalid signature header format' };
  }

  // Check timestamp tolerance (prevents replay attacks)
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
    return { valid: false, error: 'Timestamp expired' };
  }

  // Compute expected signature
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex');

  // Timing-safe comparison
  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );

  return isValid ? { valid: true } : { valid: false, error: 'Signature mismatch' };
}

start();

Testing Your Verification

Generate a Test Signature

Use this script to generate a test signature:
# Node.js
node -e "
const crypto = require('crypto');
const payload = '{\"event_type\":\"prescription.created\",\"event_id\":\"evt_test123\",\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"partner_id\":\"demo\",\"organization_id\":\"org-123\",\"data\":{\"patient_id\":\"p-1\",\"partner_patient_id\":\"pp-1\",\"user_id\":\"u-1\",\"scid\":\"SC123\"}}';
const timestamp = Math.floor(Date.now() / 1000);
const secret = 'whsec_your_test_secret';
const signedPayload = timestamp + '.' + payload;
const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
console.log('X-Webhook-Signature: t=' + timestamp + ',v1=' + signature);
console.log('Payload:', payload);
"

Test with cURL

curl -X POST https://your-endpoint.com/webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: t=<timestamp>,v1=<signature>" \
  -d '{"event_type":"prescription.created","event_id":"evt_test123","timestamp":"2026-01-01T00:00:00.000Z","partner_id":"demo","organization_id":"org-123","data":{"patient_id":"p-1","partner_patient_id":"pp-1","user_id":"u-1","scid":"SC123"}}'

Common Issues

”Timestamp outside tolerance window”

Cause: The webhook timestamp is more than 5 minutes old. Solutions:
  • Ensure your server clock is synchronized (use NTP)
  • Check for network delays
  • Verify your server isn’t taking too long to process requests

”Signature mismatch”

Cause: The computed signature doesn’t match the provided signature. Solutions:
  • Ensure you’re using the correct webhook secret
  • Verify you’re using the raw request body (not parsed JSON)
  • Check you’re not modifying the body before verification
  • Ensure proper UTF-8 encoding

”Invalid signature header format”

Cause: The signature header is malformed. Solutions:
  • Verify you’re reading the X-Webhook-Signature header correctly
  • Check for any middleware that might be modifying headers

Security Best Practices

DO verify signatures before processing
DO use timing-safe comparison functions
DO check timestamp tolerance (prevent replay attacks)
DO store webhook secrets in a secrets manager (e.g. AWS Secrets Manager, HashiCorp Vault)
DO log verification failures for security monitoring
DON’T skip signature verification
DON’T use simple string comparison (vulnerable to timing attacks)
DON’T commit webhook secrets to version control
DON’T modify the request body before verification

Troubleshooting Checklist

When debugging webhook verification:
  • Using HTTPS endpoint?
  • Reading X-Webhook-Signature header correctly?
  • Using raw request body (not parsed JSON)?
  • Using correct webhook secret?
  • Server clock synchronized?
  • Timing-safe comparison implemented?
  • Checking timestamp tolerance?
  • Logging verification failures?

Next Steps