Verifying Webhook Signatures
Meldoc signs every request to your endpoint with HMAC-SHA256. Verifying the signature confirms the request came from Meldoc and wasn’t replayed or tampered with.
Request headers
| Header | Value |
|---|---|
X-Meldoc-Event |
Event type, e.g. doc.published |
X-Meldoc-Delivery-Id |
UUID for this delivery attempt |
X-Meldoc-Signature |
sha256=<hex_digest> |
X-Meldoc-Timestamp |
Unix epoch seconds |
Signature formula
sha256=hex( HMAC-SHA256(secret, "<timestamp>.<raw_body>") )
The secret is the mdw_... value you copied when creating the webhook.
Keep in mind: Verify over the raw bytes of the body, before any JSON parsing. Always use a constant-time comparison function to prevent timing attacks.
Three rules
- Read
X-Meldoc-Timestampand reject the request if it is more than 5 minutes from the current time. This prevents replay attacks. - Compute the expected signature over
<timestamp>.<raw_body>and compare toX-Meldoc-Signaturewith a constant-time function. - Verify before parsing JSON, not after.
Node.js
const crypto = require('crypto');
const MAX_SKEW_SECONDS = 300;
function verify(secret, timestamp, rawBody, header) {
if (!header?.startsWith('sha256=')) return false;
const mac = crypto.createHmac('sha256', secret);
mac.update(String(timestamp));
mac.update('.');
mac.update(rawBody);
const expected = Buffer.from(mac.digest('hex'), 'hex');
const got = Buffer.from(header.slice('sha256='.length), 'hex');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
// Express — raw body middleware required:
app.post('/meldoc-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = parseInt(req.header('X-Meldoc-Timestamp') ?? '', 10);
if (!ts || Math.abs(Math.floor(Date.now() / 1000) - ts) > MAX_SKEW_SECONDS) {
return res.status(401).send('stale timestamp');
}
if (!verify(process.env.MELDOC_SECRET, ts, req.body, req.header('X-Meldoc-Signature'))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// deduplicate on event.delivery_id before processing
res.status(204).end();
});
Python
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["MELDOC_SECRET"].encode()
MAX_SKEW = 300
@app.post("/meldoc-webhook")
def receive():
try:
ts = int(request.headers.get("X-Meldoc-Timestamp", ""))
except ValueError:
abort(401)
if abs(int(time.time()) - ts) > MAX_SKEW:
abort(401)
header = request.headers.get("X-Meldoc-Signature", "")
if not header.startswith("sha256="):
abort(401)
signed = f"{ts}.".encode() + request.data
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header[len("sha256="):]):
abort(401)
event = request.get_json(force=True)
# deduplicate on event["delivery_id"] before processing
return "", 204
Go
package receiver
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const maxSkew = 5 * time.Minute
func verify(secret []byte, ts int64, body []byte, header string) bool {
if !strings.HasPrefix(header, "sha256=") {
return false
}
got, err := hex.DecodeString(strings.TrimPrefix(header, "sha256="))
if err != nil {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(strconv.FormatInt(ts, 10)))
mac.Write([]byte{'.'})
mac.Write(body)
return hmac.Equal(mac.Sum(nil), got)
}
func Handler(secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ts, err := strconv.ParseInt(r.Header.Get("X-Meldoc-Timestamp"), 10, 64)
if err != nil || time.Since(time.Unix(ts, 0)).Abs() > maxSkew {
http.Error(w, "stale timestamp", http.StatusUnauthorized)
return
}
body, _ := io.ReadAll(r.Body)
if !verify(secret, ts, body, r.Header.Get("X-Meldoc-Signature")) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// deduplicate on X-Meldoc-Delivery-Id before processing
w.WriteHeader(http.StatusNoContent)
}
}
Idempotency
Meldoc delivers events at-least-once. A retry after a network error can resend the same payload. Deduplicate on X-Meldoc-Delivery-Id (or the delivery_id field in the body) before running your handler to process each event exactly once.
What’s next?
Delivery and Retries — Retry schedule and the delivery log.
Webhook Events — Full event catalog and payload shapes.