HMAC signature
Every webhook sent by DPay Web EID is digitally signed using HMAC-SHA256. Verifying the signature confirms that the webhook genuinely came from DPay and was not modified in transit.
Security
Always verify the signature before processing a webhook. Without verification, anyone can send a fake webhook to your endpoint and impersonate DPay.
Algorithm
signature = HMAC-SHA256(secret, "{timestamp}.{raw_body}")
Where:
secret- the HMAC key (whsec_xxx) returned when the webhook was registeredtimestamp- theX-Timestampheader value as a Unix timestampraw_body- the exact raw request body before JSON parsing
Verification steps
- Read
X-TimestampandX-Signaturefrom the HTTP headers - Read the raw request body, not parsed JSON
- Calculate
HMAC-SHA256(secret, "{timestamp}.{raw_body}") - Remove the
sha256=prefix and compare the result withX-Signature - Use a function that is resistant to timing attacks, such as
hash_equalsorhmac.compare_digest
PHP example
<?php
function verifyWebhookSignature(
string $payload,
string $signature,
string $timestamp,
string $secret
): bool {
$expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
$actual = str_replace('sha256=', '', $signature);
return hash_equals($expected, $actual);
}
// Usage in a controller:
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$secret = getenv('DPAY_EID_WEBHOOK_SECRET'); // whsec_xxx
if (!verifyWebhookSignature($payload, $signature, $timestamp, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
// Signature valid - process the event
$event = json_decode($payload, true);
$eventType = $event['type'];
$sessionId = $event['data']['verification_session_id'];
switch ($eventType) {
case 'verification.completed':
$resultStatus = $event['data']['result_status'];
// Retrieve the full result from /verifications/{id}/result
break;
case 'verification.failed':
// Handle the rejection
break;
}
http_response_code(200);
echo 'OK';
Python example (Flask)
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['DPAY_EID_WEBHOOK_SECRET']
def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
"""Verify the webhook HMAC-SHA256 signature."""
message = f"{timestamp}.{payload.decode('utf-8')}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
actual = signature.replace("sha256=", "")
return hmac.compare_digest(expected, actual)
@app.route("/webhooks/dpay", methods=["POST"])
def handle_webhook():
payload = request.get_data() # Raw body; DO NOT use request.json
signature = request.headers.get("X-Signature", "")
timestamp = request.headers.get("X-Timestamp", "")
if not verify_signature(payload, signature, timestamp):
abort(401)
event = request.get_json()
event_type = event["type"]
if event_type == "verification.completed":
session_id = event["data"]["verification_session_id"]
result_status = event["data"].get("result_status")
# Process the result...
return "", 200
Node.js example (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = process.env.DPAY_EID_WEBHOOK_SECRET;
// Important: preserve the raw body; DO NOT use express.json() before verification
app.use('/webhooks/dpay', express.raw({ type: 'application/json' }));
function verifySignature(payload, signature, timestamp) {
const message = `${timestamp}.${payload.toString('utf-8')}`;
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(message)
.digest('hex');
const actual = signature.replace('sha256=', '');
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(actual, 'hex')
);
}
app.post('/webhooks/dpay', (req, res) => {
const signature = req.headers['x-signature'] || '';
const timestamp = req.headers['x-timestamp'] || '';
if (!verifySignature(req.body, signature, timestamp)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf-8'));
switch (event.type) {
case 'verification.completed':
// Process the result
break;
case 'verification.failed':
// Handle the rejection
break;
}
res.status(200).send('OK');
});
Common errors
| Error | Cause | Solution |
|---|---|---|
| Signature never matches | Parsed JSON is used instead of the raw body | Read the raw request body (php://input, request.get_data(), or req.body with express.raw()) |
| Signature occasionally does not match | Middleware modifies the body, for example by adding whitespace | Register raw-body handling before all JSON middleware |
hash_equals throws an error | Strings have different lengths | First check that the headers are present, then compare them |
| 401 in production but successful in the sandbox | Each environment uses a different secret | Each environment has its own webhook and secret |
| Timing attack | === is used instead of hash_equals | Always use a timing-safe comparison |
Replay attack protection
You can also verify that X-Timestamp is recent, for example no more than five minutes old:
$timestamp = (int) $_SERVER['HTTP_X_TIMESTAMP'];
$now = time();
if (abs($now - $timestamp) > 300) { // 5 minutes
http_response_code(401);
exit('Webhook timestamp too old');
}
This protects against replay attacks, in which an older, previously captured webhook is sent again.