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=4f8a2b3e7c5d6f8a2b3e7c5d6f8a2b3e7c5d6f8a2b3e7c5d6f8a2b3e7c5d6f8aTwo comma-separated key-value pairs:
| Key | Meaning |
|---|---|
t | Unix timestamp (seconds) when the body was signed. |
v1 | Hex-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
- Read the raw request body (bytes, not parsed JSON).
- Read
Dolores-Signatureheader; split on,and=. - Reject if
tis more than 5 minutes off from your server clock (replay protection). - Recompute
HMAC-SHA256(secret, "${t}.${rawBody}")and hex-encode it. - 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 '', 200Ruby (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
endPHP
<?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.