Docs · Webhooks
Webhook signing & verification
Every webhook we send is signed with HMAC-SHA256 using the shared secret you received when you configured your endpoint. Verify the signature on every request. If it doesn't match, reject.
- HMAC-SHA256 signed
- 5-minute timestamp tolerance
- Retried on non-2xx (exponential backoff)
- Replay-protected via
X-DD-Delivery-Id
Sample · account.cured event
POST https://your-endpoint.example.com/webhooks/dd
X-DD-Event: account.cured
X-DD-Signature: sha256=7a38bd29ef0c…
X-DD-Timestamp: 2026-05-06T12:34:56Z
X-DD-Delivery-Id: 01h8k2rjm7c3q4v5
Content-Type: application/json
{
"event": "account.cured",
"account_id": "a-7f2c…",
"consumer_id": "c-3a91…",
"cured_at": "2026-05-06T12:34:50Z",
"principal_balance": 5247.63,
"days_to_cure": 47
}
Delivery shape
We POST JSON to your configured URL. Relevant headers:
X-DD-Event: account.cured X-DD-Signature: sha256=7a38bd29... X-DD-Timestamp: 2026-04-20T12:34:56Z X-DD-Delivery-Id: 01h8k2rjm7c3q4v5 Content-Type: application/json
The signature is computed over the raw request body. Do not JSON-parse before verifying.
Replay protection. Reject any request with
X-DD-Timestamp outside a ±5-minute window. We include it so you don't have to track delivery IDs for this alone.Node.js (Express / raw http)
const crypto = require('crypto');
function verifyWebhook(req, rawBody, secret) {
const received = (req.headers['x-dd-signature'] || '').replace(/^sha256=/, '');
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Timing-safe compare
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(
Buffer.from(received, 'hex'),
Buffer.from(expected, 'hex')
);
}
// Express: use express.raw() so you get the raw buffer
app.post('/webhooks/debt-digest',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!verifyWebhook(req, req.body, process.env.DD_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// handle event.type ...
res.sendStatus(200);
});
Python (Flask)
import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
def verify(req, secret):
received = req.headers.get('X-DD-Signature', '').replace('sha256=', '')
expected = hmac.new(
secret.encode('utf-8'),
req.get_data(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(received, expected)
@app.post('/webhooks/debt-digest')
def webhook():
if not verify(request, os.environ['DD_WEBHOOK_SECRET']):
abort(401)
event = request.get_json()
# handle event['type'] ...
return '', 200
Ruby (Rails)
require 'openssl'
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def debt_digest
received = request.headers['X-DD-Signature'].to_s.sub(/^sha256=/, '')
expected = OpenSSL::HMAC.hexdigest(
'sha256',
ENV.fetch('DD_WEBHOOK_SECRET'),
request.raw_post
)
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(received, expected)
event = JSON.parse(request.raw_post)
# handle event['type'] ...
head :ok
end
end
Go (net/http)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
received := strings.TrimPrefix(r.Header.Get("X-DD-Signature"), "sha256=")
mac := hmac.New(sha256.New, []byte(os.Getenv("DD_WEBHOOK_SECRET")))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(received), []byte(expected)) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event map[string]any
json.Unmarshal(body, &event)
// handle event["type"] ...
w.WriteHeader(http.StatusOK)
}
Our delivery guarantees
- At-least-once. We may retry on 5xx or network timeout. Your handler must be idempotent on
X-DD-Delivery-Id. - Retry schedule. Exponential backoff with jitter: 30s, 2m, 10m, 1h, 6h, 24h. After 6 failures we mark the endpoint unhealthy and email your configured contact.
- Ordering. Not guaranteed. Include the event's own timestamp in your idempotency logic.
- Delivery log. Every attempt is logged in your dashboard's Webhook Deliveries view (attempt count, status, response body excerpt).
Rotating your secret
Rotate any time from the Settings tab in your dashboard. We accept either the old or new secret for a 24-hour grace window after rotation - sign with both during rollover if your handler caches secrets.