Security
Two layers protect the communication: the HMAC signature (proves the payload came from Dunning and was not tampered with) and request authentication (credentials Dunning presents to your endpoint). The first is always included; the second is optional.
HMAC signature
Every delivery carries the header:
X-Webhook-Signature: t=<unix>,v1=<hex>
tis the timestamp (unix, seconds) of the moment of sending — each retry is re-signed with a fresht.v1is the HMAC-SHA256 of"{t}.{body}"(the timestamp, a dot and the raw request body), computed with the endpoint's secret (thatwhsec_...shown only once at registration), in lowercase hexadecimal.
Binding the timestamp to the signature closes off replay: an attacker who captures a delivery cannot replay it later, because t falls outside the tolerance window — and cannot swap t without invalidating the HMAC. Recommended tolerance: 5 minutes.
Verifying the signature
Extract t and v1, reject timestamps outside the tolerance and compare the HMAC of "{t}.{body}" in constant time (always over the raw body, before any JSON parsing). The algorithm is the same in any language — the same verifier in Node.js, Python, PHP and Ruby:
- Node.js
- Python
- PHP
- Ruby
const crypto = require('crypto');
const TOLERANCE_SECONDS = 300; // 5 minutes
function verifySignature(rawBody, signatureHeader, secret) {
const t = /(?:^|,)t=(\d+)(?:,|$)/.exec(signatureHeader || '')?.[1];
const v1 = /(?:^|,)v1=([0-9a-f]+)(?:,|$)/i.exec(signatureHeader || '')?.[1];
if (!t || !v1) return false;
// Anti-replay: reject signatures outside the tolerance window
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1.toLowerCase(), 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Example with Express (note the raw body parser):
app.post('/webhooks/dunning', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifySignature(
req.body, // Buffer with the raw body
req.get('X-Webhook-Signature'),
process.env.DUNNING_WEBHOOK_SECRET
);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body);
// queue it and respond fast
res.status(200).end();
});
import hashlib
import hmac
import os
import re
import time
from flask import Flask, abort, request
app = Flask(__name__)
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
t_match = re.search(r'(?:^|,)t=(\d+)(?:,|$)', signature_header or '')
v1_match = re.search(r'(?:^|,)v1=([0-9a-f]+)(?:,|$)', signature_header or '', re.I)
if not t_match or not v1_match:
return False
t = int(t_match.group(1))
# Anti-replay: reject signatures outside the tolerance window
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f'{t}.'.encode() + raw_body, # concatenate the raw body bytes
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1_match.group(1).lower())
@app.post('/webhooks/dunning')
def webhook():
raw = request.get_data() # raw body, before JSON parsing
ok = verify_signature(
raw,
request.headers.get('X-Webhook-Signature', ''),
os.environ['DUNNING_WEBHOOK_SECRET'],
)
if not ok:
abort(401)
# queue it and respond fast
return '', 200
<?php
const TOLERANCE_SECONDS = 300; // 5 minutes
function verify_signature(string $rawBody, string $signatureHeader, string $secret): bool
{
if (!preg_match('/(?:^|,)t=(\d+)(?:,|$)/', $signatureHeader, $tm)) {
return false;
}
if (!preg_match('/(?:^|,)v1=([0-9a-f]+)(?:,|$)/i', $signatureHeader, $vm)) {
return false;
}
$t = (int) $tm[1];
// Anti-replay: reject signatures outside the tolerance window
if (abs(time() - $t) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($vm[1])); // constant-time comparison
}
$rawBody = file_get_contents('php://input'); // raw body, before JSON parsing
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!verify_signature($rawBody, $header, getenv('DUNNING_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
// queue it and respond fast
http_response_code(200);
require 'openssl'
require 'rack'
require 'sinatra'
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_signature(raw_body, signature_header, secret)
t = signature_header&.match(/(?:^|,)t=(\d+)(?:,|$)/)&.captures&.first
v1 = signature_header&.match(/(?:^|,)v1=([0-9a-f]+)(?:,|$)/i)&.captures&.first
return false unless t && v1
# Anti-replay: reject signatures outside the tolerance window
return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw_body}")
# secure_compare is constant-time and already handles differing lengths
Rack::Utils.secure_compare(expected, v1.downcase)
end
# Example with Sinatra (raw body, before JSON parsing):
post '/webhooks/dunning' do
raw = request.body.read
unless verify_signature(raw, request.env['HTTP_X_WEBHOOK_SIGNATURE'], ENV['DUNNING_WEBHOOK_SECRET'])
halt 401
end
# queue it and respond fast
status 200
end
Classic pitfalls:
- Do not re-serialize the JSON to compute the HMAC: any difference in whitespace or key order changes the signature. Use the body exactly as it arrived.
- Concatenate in the right order: the HMAC covers
t, a dot (.) and the body — in that order. - Use constant-time comparison (
crypto.timingSafeEqual), never===: a plain comparison leaks information through timing. - A skewed clock on your server breaks legitimate verifications: keep NTP up to date before tightening the tolerance.
- Reject requests without the header or with an invalid signature with
401, without detailing the reason.
A retry of the same delivery arrives with a different t (and signature) — the tolerance window applies to every attempt. To deduplicate deliveries, use the X-Webhook-Delivery-Id header, not the signature.
Request authentication
If your endpoint sits behind a gateway that requires credentials, configure authentication at registration:
| Type | What Dunning sends |
|---|---|
None (none) | Only the HMAC signature |
Basic (basic) | Authorization: Basic <usuário:senha> |
Bearer (bearer) | Authorization: Bearer <token> |
Header (header) | A custom header with a name and value you define |
Credentials are encrypted at rest (AES-256-GCM) and never appear in the delivery trail: in the request/response capture, the signature and the authentication headers are stored as [REDACTED].
Secret rotation
The rotation button (on the endpoint's row) generates a new secret, shown only once, and invalidates the previous one immediately (also available via POST /api/v1/webhook-endpoints/{id}/rotate-secret). Rotate on suspicion of a leak or as periodic hygiene; update the consumer at the same moment, since deliveries signed with the old secret will stop validating.
Protections on the Dunning side
- HTTPS only:
http://URLs are refused at registration. - Anti-SSRF: the URL is validated at registration and revalidated on every delivery (DNS re-resolved, IP pinned, internal networks blocked). Redirects are not followed. A destination that starts resolving to an internal network is blocked permanently (
SSRF blocked, no retry). - Responses from your endpoint are captured with sensitive headers (
Set-Cookie,Authorization...) redacted.