TryMellon
Navigation

Webhook signature verification

Verify the HMAC-SHA256 signature on incoming tryMellon webhooks.

Webhook signature verification

Every webhook request carries an HMAC-SHA256 signature of the raw body. Verify it before trusting the payload — otherwise an attacker who guesses your webhook URL can inject events.

Headers

HeaderValue
tm-signatureHex-encoded HMAC_SHA256(secret, raw_body)
tm-timestampRFC 3339 timestamp of when the request was sent
tm-event-idUUID of the delivery (use for idempotency)

The signing secret is the application’s webhook_signing_secret, generated when the webhook URL is set, and rotatable from the dashboard. It is not the client_secret.

Verify (Node.js)

import crypto from 'node:crypto';
import express from 'express';

const app = express();

// IMPORTANT: use raw body, not parsed JSON. Re-encoding changes whitespace
// and breaks the signature.
app.post('/webhooks/trymellon',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.header('tm-signature') ?? '';
    const expected = crypto
      .createHmac('sha256', process.env.TRYMELLON_WEBHOOK_SECRET!)
      .update(req.body)
      .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // …handle event
    res.status(200).end();
  },
);

Verify (Go)

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

func verify(w http.ResponseWriter, r *http.Request) bool {
    body, _ := io.ReadAll(r.Body)
    mac := hmac.New(sha256.New, []byte(os.Getenv("TRYMELLON_WEBHOOK_SECRET")))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(r.Header.Get("tm-signature")), []byte(expected))
}

Verify (Python — Flask)

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

app = Flask(__name__)

@app.post("/webhooks/trymellon")
def webhook():
    secret = os.environ["TRYMELLON_WEBHOOK_SECRET"].encode()
    expected = hmac.new(secret, request.data, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("tm-signature", "")):
        abort(401)
    # …handle JSON via request.get_json()
    return ("", 200)

Verify (Ruby — Rails)

class WebhooksController < ActionController::API
  def trymellon
    body = request.raw_post
    expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('TRYMELLON_WEBHOOK_SECRET'), body)
    head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(
      expected, request.headers['tm-signature'].to_s
    )
    # …handle event
    head :ok
  end
end

Replay protection

Reject requests where tm-timestamp is older than 5 minutes. Combined with the tm-event-id idempotency check, this stops captured-and-replayed deliveries.

const sentAt = Date.parse(req.header('tm-timestamp') ?? '');
if (Math.abs(Date.now() - sentAt) > 5 * 60_000) return res.status(401).end();

Common mistakes

MistakeSymptomFix
Verifying after express.json()Signatures never match.Use express.raw() on the webhook route.
Using === to compare signaturesVulnerable to timing attacks.Use crypto.timingSafeEqual / hmac.compare_digest.
Using client_secret to sign401 every request.Use the dashboard’s webhook signing secret.
Logging the secretCredential leak.Never log the secret or the raw signature in production.