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:
- Creates a signed payload:
timestamp.json_body
- Computes HMAC SHA-256 signature using your webhook secret
- Sends the signature in the
X-Webhook-Signature header
- Your application verifies the signature matches
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
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
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:
Next Steps