DoloresDocs
Webhooks

Verifying signatures

Authenticate webhook deliveries with HMAC-SHA256 in any language.

Dolores signs every webhook delivery with HMAC-SHA256 using your endpoint's signing secret. Verify the signature in your handler before trusting the payload — never allowlist by source IP, since Dolores delivers from a pool of egress addresses that can change.

The signature header

Every delivery carries:

Dolores-Signature: t=1717685011,v1=4f8a2b3e7c5d6f8a2b3e7c5d6f8a2b3e7c5d6f8a2b3e7c5d6f8a2b3e7c5d6f8a

Two comma-separated key-value pairs:

KeyMeaning
tUnix timestamp (seconds) when the body was signed.
v1Hex-encoded HMAC-SHA256 of ${t}.${rawBody} using your endpoint's secret.

If we ever introduce a v2 signature, we'll include both v1 and v2 simultaneously so existing handlers don't break.

Verification recipe

  1. Read the raw request body (bytes, not parsed JSON).
  2. Read Dolores-Signature header; split on , and =.
  3. Reject if t is more than 5 minutes off from your server clock (replay protection).
  4. Recompute HMAC-SHA256(secret, "${t}.${rawBody}") and hex-encode it.
  5. Constant-time compare against v1. If they differ, reject with 400.

Node.js

The most common bug: using express.json() before signature verification. JSON re-serialization changes whitespace and breaks the HMAC. Use express.raw({ type: 'application/json' }) instead:

const express = require('express');
const crypto = require('crypto');

const app = express();
const SECRET = process.env.DOLORES_WEBHOOK_SECRET;

function verifyDoloresSignature(rawBody, header, secret, toleranceSec = 300) {
  if (!header) throw new Error('missing signature header');
  const parts = Object.fromEntries(header.split(',').map(kv => {
    const i = kv.indexOf('=');
    return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
  }));
  const t = parseInt(parts.t, 10);
  if (!Number.isFinite(t)) throw new Error('bad timestamp');
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) {
    throw new Error('timestamp out of tolerance');
  }
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const provided = Buffer.from(parts.v1, 'hex');
  if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
    throw new Error('signature mismatch');
  }
}

app.post('/dolores/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    verifyDoloresSignature(req.body.toString('utf8'), req.headers['dolores-signature'], SECRET);
  } catch (err) {
    return res.status(400).send('signature verification failed');
  }
  const event = JSON.parse(req.body.toString('utf8'));
  // dispatch...
  res.status(200).send();
});

Python (Flask)

import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['DOLORES_WEBHOOK_SECRET'].encode()

def verify(raw_body: str, header: str, secret: bytes, tolerance_sec: int = 300) -> None:
    if not header:
        raise ValueError('missing signature header')
    parts = dict(kv.split('=', 1) for kv in header.split(','))
    t = int(parts['t'])
    if abs(time.time() - t) > tolerance_sec:
        raise ValueError('timestamp out of tolerance')
    expected = hmac.new(secret, f"{t}.{raw_body}".encode(), hashlib.sha256).digest()
    provided = bytes.fromhex(parts['v1'])
    if not hmac.compare_digest(expected, provided):
        raise ValueError('signature mismatch')

@app.post('/dolores/webhook')
def hook():
    try:
        verify(request.data.decode('utf-8'), request.headers.get('Dolores-Signature', ''), SECRET)
    except Exception:
        abort(400)
    event = request.get_json()
    # dispatch...
    return '', 200

Ruby (Rack)

require 'openssl'
require 'json'

SECRET = ENV.fetch('DOLORES_WEBHOOK_SECRET')

def verify_dolores_signature(raw_body, header, secret, tolerance_sec = 300)
  raise 'missing signature header' if header.nil? || header.empty?
  parts = Hash[header.split(',').map { |kv| kv.split('=', 2) }]
  t = parts['t'].to_i
  raise 'timestamp out of tolerance' if (Time.now.to_i - t).abs > tolerance_sec
  expected = OpenSSL::HMAC.digest('SHA256', secret, "#{t}.#{raw_body}")
  provided = [parts['v1']].pack('H*')
  raise 'signature mismatch' unless expected.bytesize == provided.bytesize \
                                  && OpenSSL.fixed_length_secure_compare(expected, provided)
end

# Sinatra example
post '/dolores/webhook' do
  raw = request.body.read
  begin
    verify_dolores_signature(raw, request.env['HTTP_DOLORES_SIGNATURE'], SECRET)
  rescue
    halt 400, 'signature verification failed'
  end
  event = JSON.parse(raw)
  # dispatch...
  status 200
end

PHP

<?php
$secret = getenv('DOLORES_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_DOLORES_SIGNATURE'] ?? '';

function verifyDoloresSignature(string $rawBody, string $header, string $secret, int $toleranceSec = 300): void {
    if ($header === '') throw new \Exception('missing signature header');
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = explode('=', $kv, 2);
        $parts[trim($k)] = trim($v);
    }
    $t = (int) $parts['t'];
    if (abs(time() - $t) > $toleranceSec) throw new \Exception('timestamp out of tolerance');
    $expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
    if (!hash_equals($expected, $parts['v1'])) throw new \Exception('signature mismatch');
}

try {
    verifyDoloresSignature($rawBody, $header, $secret);
} catch (\Exception $e) {
    http_response_code(400);
    exit;
}
$event = json_decode($rawBody, true);
// dispatch...
http_response_code(200);

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var secret = []byte(os.Getenv("DOLORES_WEBHOOK_SECRET"))

func verify(rawBody, header string, secret []byte, toleranceSec int64) error {
    if header == "" {
        return errors.New("missing signature header")
    }
    parts := map[string]string{}
    for _, kv := range strings.Split(header, ",") {
        i := strings.Index(kv, "=")
        if i <= 0 {
            continue
        }
        parts[strings.TrimSpace(kv[:i])] = strings.TrimSpace(kv[i+1:])
    }
    t, err := strconv.ParseInt(parts["t"], 10, 64)
    if err != nil {
        return err
    }
    if abs(time.Now().Unix()-t) > toleranceSec {
        return errors.New("timestamp out of tolerance")
    }
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(strconv.FormatInt(t, 10) + "." + rawBody))
    expected := mac.Sum(nil)
    provided, err := hex.DecodeString(parts["v1"])
    if err != nil {
        return err
    }
    if !hmac.Equal(expected, provided) {
        return errors.New("signature mismatch")
    }
    return nil
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)
    if err := verify(string(raw), r.Header.Get("Dolores-Signature"), secret, 300); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    // dispatch parsed event...
    w.WriteHeader(http.StatusOK)
}

Rotating the secret

If you suspect the secret has leaked, rotate it from the admin UI (Settings → Webhooks → Rotate secret) or via API:

curl -X POST "https://app.meetdolores.ai/v1/webhook_endpoints/whk_.../rotate_secret" \
  -H "Authorization: Bearer $DOLORES_API_KEY"

The response returns the new secret once. Switch your handler over to the new value as soon as possible — the old secret stops signing new deliveries immediately.