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.

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

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.