Documentation

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

  1. Read X-Meldoc-Timestamp and reject the request if it is more than 5 minutes from the current time. This prevents replay attacks.
  2. Compute the expected signature over <timestamp>.<raw_body> and compare to X-Meldoc-Signature with a constant-time function.
  3. 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.