Skip to main content

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 registered
  • timestamp - the X-Timestamp header value as a Unix timestamp
  • raw_body - the exact raw request body before JSON parsing

Verification steps

  1. Read X-Timestamp and X-Signature from the HTTP headers
  2. Read the raw request body, not parsed JSON
  3. Calculate HMAC-SHA256(secret, "{timestamp}.{raw_body}")
  4. Remove the sha256= prefix and compare the result with X-Signature
  5. Use a function that is resistant to timing attacks, such as hash_equals or hmac.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

ErrorCauseSolution
Signature never matchesParsed JSON is used instead of the raw bodyRead the raw request body (php://input, request.get_data(), or req.body with express.raw())
Signature occasionally does not matchMiddleware modifies the body, for example by adding whitespaceRegister raw-body handling before all JSON middleware
hash_equals throws an errorStrings have different lengthsFirst check that the headers are present, then compare them
401 in production but successful in the sandboxEach environment uses a different secretEach environment has its own webhook and secret
Timing attack=== is used instead of hash_equalsAlways 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.