Webhook Security & Verification
Securing your webhook endpoints is critical to prevent unauthorized access and ensure data integrity. The payment gateway uses HMAC-SHA256 signatures to sign all webhook payloads.
Signature Verification
How Signatures Work
Every webhook request includes three security headers:
X-PaymentService-Event: payment.completed
X-PaymentService-Timestamp: 1706356245
X-PaymentService-Signature: a1b2c3d4e5f6g7h8i9j0...
The signature is calculated as:
HMAC-SHA256(
key: your_webhook_secret,
message: "{timestamp}.{json_payload}"
)
Where:
timestampis the value fromX-PaymentService-Timestampjson_payloadis the raw JSON body of the request- The two are concatenated with a
.separator
Verification Implementation
const crypto = require('node:crypto');
function verifyWebhookSignature(rawBody, timestamp, signature, secret) {
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
);
}
// Express middleware
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-paymentservice-signature'];
const timestamp = req.headers['x-paymentservice-timestamp'];
const secret = process.env.WEBHOOK_SECRET;
if (!signature || !timestamp) {
return res.status(401).json({ error: 'Missing signature headers' });
}
if (!verifyWebhookSignature(req.body.toString(), timestamp, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
// Process verified webhook...
res.status(200).send('OK');
});
Timestamp Verification
Prevent replay attacks by validating the timestamp:
function verifyTimestamp(timestamp, maxAgeSeconds = 300) {
const now = Math.floor(Date.now() / 1000);
const webhookTime = Number.parseInt(timestamp);
const age = now - webhookTime;
return age >= 0 && age <= maxAgeSeconds;
}
Webhook Secret
The webhook secret is the secret value you provide when creating your payment application. This same secret is used to sign all webhook payloads sent to your callbackUrl.
Store it securely:
# .env file
WEBHOOK_SECRET=your_webhook_secret_from_payment_app
Idempotency
Prevent duplicate event processing by tracking processed payment IDs:
async function handleWebhook(event) {
const paymentId = event.payment.id;
const status = event.payment.status;
const key = `${paymentId}:${status}`;
// Check if already processed
const processed = await redis.get(`webhook:${key}`);
if (processed) {
return { status: 'already_processed' };
}
// Process event
await processPaymentEvent(event);
// Mark as processed (expire after 24 hours)
await redis.setex(`webhook:${key}`, 86400, '1');
return { status: 'success' };
}
Security Checklist
- Verify HMAC-SHA256 signatures on all webhooks
- Use timing-safe comparison for signatures
- Validate timestamps to prevent replay attacks
- Store webhook secrets securely (environment variables)
- Implement idempotency checks
- Use HTTPS for webhook endpoints
- Respond with 200 within timeout to avoid retries
- Log all webhook attempts for debugging