Webhooks

Verifying Signatures

Every webhook delivery includes a Trumpet-Signature header so you can verify the request genuinely came from trumpet and was not tampered with. Always verify before acting on a payload.


The signature header format

The Trumpet-Signature header has this form:

Trumpet-Signature: t=1717160000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
FieldDescription
tUnix timestamp (seconds) at which trumpet signed and sent the request.
v1Hex-encoded HMAC-SHA256 signature.

How the signature is computed

trumpet computes the signature as:

HMAC_SHA256( signing_secret, "{t}.{rawBody}" )
  • The signed string is the timestamp, a literal . (full stop), then the exact raw request body bytes.
  • The key is your endpoint's signing secret including the whsec_ prefix — use the full secret string exactly as shown when you created the endpoint.
  • The result is hex-encoded and placed in v1.

Verification steps

1

Read the raw body.

Capture the request body as raw bytes or a string before any JSON parsing. Parsing and re-serialising will change the bytes and break verification.

⚠️

Do not parse before verifying

This is the single most common mistake. Many web frameworks automatically parse JSON request bodies — ensure you read the raw bytes first and pass those to the verification function.
2

Parse the header

Split the Trumpet-Signature header on , and extract the t and v1 values.

3

Recompute

Compute HMAC_SHA256(signing_secret, "{t}.{rawBody}") and hex-encode it. Use the full whsec_-prefixed secret as the key.

4

Compare in constant time

Compare your computed value to v1 using a constant-time comparison (e.g. crypto.timingSafeEqual in Node, hmac.compare_digest in Python), not ==. This prevents timing-based attacks.

5

Check the timestamp

Reject the request if t is more than 5 minutes in the past (or future) relative to your server clock. This defends against replay attacks where an attacker re-sends a previously captured valid request.


Code samples

Complete, runnable examples. Both use the raw body, sign "{t}.{rawBody}", use the full whsec_ secret, compare in constant time, and enforce the 5-minute window.

JavaScript (Node / Express)
import crypto from 'node:crypto';

// Use express.raw({ type: 'application/json' }) (or equivalent) so that
// req.body contains the raw Buffer — do not let body-parsing middleware
// parse it first.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const isValid = verifyTrumpetWebhook(
    req.body,
    req.headers['trumpet-signature'],
    process.env.TRUMPET_WEBHOOK_SECRET,
  );

  if (!isValid) {
    return res.status(400).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  // handle event...
  res.status(200).send('OK');
});

function verifyTrumpetWebhook(rawBody, signatureHeader, signingSecret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((kv) => kv.split('=')),
  );
  const timestamp = Number(parts.t);
  const provided = parts.v1;

  // 5-minute replay window
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return false;
  }

  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(provided ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python (Flask)
import hmac, hashlib, time
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    raw_body = request.get_data()  # raw bytes — before any parsing
    signature_header = request.headers.get('Trumpet-Signature', '')
    signing_secret = os.environ['TRUMPET_WEBHOOK_SECRET']

    if not verify_trumpet_webhook(raw_body, signature_header, signing_secret):
        return 'Invalid signature', 400

    event = request.get_json(force=True)
    # handle event...
    return 'OK', 200

def verify_trumpet_webhook(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
    timestamp = parts.get("t")
    provided = parts.get("v1", "")

    if not timestamp or abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(
        signing_secret.encode(),
        f"{timestamp}.{raw_body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, provided)
💡

If verification fails

Return a 4xx and do not process the event. A failing signature means either the secret is wrong, the raw body was modified (often by body-parsing middleware), or the request did not originate from trumpet.

Regenerating the secret

You can regenerate an endpoint's signing secret at any time from Settings → Webhooks → Edit. A new whsec_-prefixed secret is generated immediately.

⚠️

Regeneration invalidates the previous secret immediately

Any in-flight deliveries that are verified against the old secret will fail after regeneration. Update the stored secret in your environment promptly to avoid a gap in delivery processing.