Webhook Security
Mittr signs every outgoing webhook. Always verify signatures to ensure authenticity.
Signature failing in production? Use the in-browser signature verifier — paste the headers + body + secret and it tells you whether they verify, and the most likely reason if they don’t. Everything runs locally; nothing leaves your browser.
Headers
Section titled “Headers”| Header | Description |
|---|---|
X-Mittr-Event-ID | Unique event ID |
X-Mittr-Timestamp | Unix timestamp when sent |
X-Mittr-Signature | HMAC-SHA256 signature |
X-Mittr-Trace-Id | End-to-end correlation ID — the original API X-Request-Id when available, otherwise a generated UUID |
Signature Format
Section titled “Signature Format”v1=HMAC-SHA256("{timestamp}.{payload}", signing_key)Verification Steps
Section titled “Verification Steps”- Extract timestamp and signature from headers
- Check timestamp is recent (< 5 minutes) to prevent replay attacks
- Compute expected signature:
HMAC-SHA256("{timestamp}.{raw_body}", secret) - Compare signatures using constant-time comparison
Examples
Section titled “Examples”import ( "crypto/hmac" "crypto/sha256" "fmt" "strconv" "time")
func VerifyWebhook(payload []byte, timestamp, signature, secret string) error { // Check timestamp freshness ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return fmt.Errorf("invalid timestamp") } if time.Now().Unix()-ts > 300 { return fmt.Errorf("timestamp too old") }
// Compute expected signature signed := fmt.Sprintf("%s.%s", timestamp, string(payload)) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signed)) expected := fmt.Sprintf("v1=%x", mac.Sum(nil))
// Constant-time comparison if !hmac.Equal([]byte(expected), []byte(signature)) { return fmt.Errorf("invalid signature") }
return nil}Python
Section titled “Python”import hmacimport hashlibimport time
def verify_webhook(payload: bytes, timestamp: str, signature: str, secret: str) -> bool: # Check timestamp freshness try: ts = int(timestamp) except ValueError: return False
if abs(time.time() - ts) > 300: return False
# Compute expected signature signed = f"{timestamp}.{payload.decode()}" expected = "v1=" + hmac.new( secret.encode(), signed.encode(), hashlib.sha256 ).hexdigest()
return hmac.compare_digest(expected, signature)Node.js
Section titled “Node.js”const crypto = require('crypto');
function verifyWebhook(payload, timestamp, signature, secret) { // Check timestamp freshness const ts = parseInt(timestamp, 10); if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > 300) { return false; }
// Compute expected signature const signed = `${timestamp}.${payload}`; const expected = 'v1=' + crypto .createHmac('sha256', secret) .update(signed) .digest('hex');
// Constant-time comparison return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signature) );}require 'openssl'
def verify_webhook(payload, timestamp, signature, secret) # Check timestamp freshness ts = timestamp.to_i return false if (Time.now.to_i - ts).abs > 300
# Compute expected signature signed = "#{timestamp}.#{payload}" expected = "v1=" + OpenSSL::HMAC.hexdigest('sha256', secret, signed)
# Constant-time comparison Rack::Utils.secure_compare(expected, signature)endfunction verifyWebhook(string $payload, string $timestamp, string $signature, string $secret): bool { // Check timestamp freshness $ts = (int) $timestamp; if (abs(time() - $ts) > 300) { return false; }
// Compute expected signature $signed = "{$timestamp}.{$payload}"; $expected = 'v1=' . hash_hmac('sha256', $signed, $secret);
// Constant-time comparison return hash_equals($expected, $signature);}Framework Examples
Section titled “Framework Examples”Express.js Middleware
Section titled “Express.js Middleware”const express = require('express');const app = express();
app.use('/webhook', express.raw({ type: '*/*' }), (req, res, next) => { const signature = req.headers['x-mittr-signature']; const timestamp = req.headers['x-mittr-timestamp'];
if (!verifyWebhook(req.body, timestamp, signature, process.env.WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); }
next();});Flask Decorator
Section titled “Flask Decorator”from functools import wrapsfrom flask import request, jsonify
def verify_mittr_signature(f): @wraps(f) def decorated(*args, **kwargs): signature = request.headers.get('X-Mittr-Signature') timestamp = request.headers.get('X-Mittr-Timestamp')
if not verify_webhook(request.data, timestamp, signature, WEBHOOK_SECRET): return jsonify({'error': 'Invalid signature'}), 401
return f(*args, **kwargs) return decorated
@app.route('/webhook', methods=['POST'])@verify_mittr_signaturedef handle_webhook(): # Process webhook...Inbound Webhook Verification
Section titled “Inbound Webhook Verification”When using Mittr’s inbound webhooks feature, Mittr verifies signatures from external sources before processing. This is configured per inbound endpoint via sourceConfig.
GitHub Webhooks
Section titled “GitHub Webhooks”GitHub sends an X-Hub-Signature-256 header with HMAC-SHA256 of the request body:
# Mittr handles this automatically, but if you need to verify manually:import hmacimport hashlib
def verify_github(payload: bytes, signature: str, secret: str) -> bool: expected = "sha256=" + hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature)Stripe Webhooks
Section titled “Stripe Webhooks”Stripe sends a Stripe-Signature header with timestamp and HMAC-SHA256:
def verify_stripe(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) timestamp = parts["t"] signature = parts["v1"]
# Check timestamp freshness import time if abs(time.time() - int(timestamp)) > tolerance: return False
# Compute expected signature signed = f"{timestamp}.{payload.decode()}" expected = hmac.new( secret.encode(), signed.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature)Generic Webhooks
Section titled “Generic Webhooks”For custom sources, configure the header name and optional prefix in sourceConfig:
{ "secret": "your-signing-secret", "header_name": "X-Webhook-Signature", "prefix": "sha256="}Mittr strips the prefix (if configured) and verifies HMAC-SHA256 of the body against the remaining signature value.
Secret rotation
Section titled “Secret rotation”Rotate endpoint signing secrets with zero downtime. During the grace period, Mittr accepts both the current and previous secret.
# Rotate the signing secret for an endpointcurl -X POST https://app.mittr.io/api/v1/endpoints/ep_uuid/rotate-secret \ -H "X-API-Key: mtr_your_key" \ -H "Content-Type: application/json" \ -d '{ "newSecret": "whsec_new_secret_value", "gracePeriodMinutes": 60 }'During the grace period:
- Mittr signs deliveries with the new secret
- Your verification code should check against both secrets
- After the grace period, the old secret is invalidated
Verification with rotation
Section titled “Verification with rotation”function verifyWithRotation(payload, timestamp, signature, secrets) { for (const secret of secrets) { const signed = `${timestamp}.${payload}`; const expected = 'v1=' + crypto .createHmac('sha256', secret) .update(signed) .digest('hex');
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) { return true; } } return false;}
// Use both current and previous secret during rotationconst isValid = verifyWithRotation(body, ts, sig, [currentSecret, previousSecret]);mTLS (Mutual TLS)
Section titled “mTLS (Mutual TLS)”For endpoints requiring client certificate authentication, configure a client certificate and private key per endpoint. Mittr presents the certificate during the TLS handshake.
curl -X PATCH https://app.mittr.io/api/v1/endpoints/ep_uuid \ -H "X-API-Key: mtr_your_key" \ -H "Content-Type: application/json" \ -d '{ "mtlsCert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", "mtlsKey": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" }'IP filtering
Section titled “IP filtering”Restrict which IPs can receive deliveries from Mittr using per-endpoint CIDR allow/block lists.
Allow list
Section titled “Allow list”Only deliver to IPs matching the allow list. All other IPs are blocked.
curl -X PATCH https://app.mittr.io/api/v1/endpoints/ep_uuid \ -H "X-API-Key: mtr_your_key" \ -H "Content-Type: application/json" \ -d '{ "allowedCIDRs": ["10.0.0.0/8", "172.16.0.0/12"] }'Block list
Section titled “Block list”Block specific IP ranges. All other IPs are allowed.
curl -X PATCH https://app.mittr.io/api/v1/endpoints/ep_uuid \ -H "X-API-Key: mtr_your_key" \ -H "Content-Type: application/json" \ -d '{ "blockedCIDRs": ["192.168.1.0/24"] }'Static egress IPs
Section titled “Static egress IPs”If your firewall requires allowlisting Mittr’s outbound IPs:
curl https://app.mittr.io/api/v1/egress-ips \ -H "X-API-Key: mtr_your_key"Returns the list of IP addresses Mittr uses for outbound webhook delivery. Add these to your firewall allow list.
SSRF protection
Section titled “SSRF protection”Mittr blocks delivery to dangerous destinations by default:
- Loopback addresses (
127.0.0.0/8,::1) - Private networks (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) - Link-local addresses (
169.254.0.0/16) - Metadata endpoints (
169.254.169.254)
This prevents endpoints from being used to probe internal infrastructure.