Skip to main content

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>
  • t is the timestamp (unix, seconds) of the moment of sending — each retry is re-signed with a fresh t.
  • v1 is the HMAC-SHA256 of "{t}.{body}" (the timestamp, a dot and the raw request body), computed with the endpoint's secret (that whsec_... 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:

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();
});

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:

TypeWhat 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.