Skip to content
mittr

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.

HeaderDescription
X-Mittr-Event-IDUnique event ID
X-Mittr-TimestampUnix timestamp when sent
X-Mittr-SignatureHMAC-SHA256 signature
X-Mittr-Trace-IdEnd-to-end correlation ID — the original API X-Request-Id when available, otherwise a generated UUID
v1=HMAC-SHA256("{timestamp}.{payload}", signing_key)
  1. Extract timestamp and signature from headers
  2. Check timestamp is recent (< 5 minutes) to prevent replay attacks
  3. Compute expected signature: HMAC-SHA256("{timestamp}.{raw_body}", secret)
  4. Compare signatures using constant-time comparison
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
}
import hmac
import hashlib
import 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)
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)
end
function 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);
}
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();
});
from functools import wraps
from 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_signature
def handle_webhook():
# Process webhook...

When using Mittr’s inbound webhooks feature, Mittr verifies signatures from external sources before processing. This is configured per inbound endpoint via sourceConfig.

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 hmac
import 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 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)

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.

Rotate endpoint signing secrets with zero downtime. During the grace period, Mittr accepts both the current and previous secret.

Terminal window
# Rotate the signing secret for an endpoint
curl -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:

  1. Mittr signs deliveries with the new secret
  2. Your verification code should check against both secrets
  3. After the grace period, the old secret is invalidated
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 rotation
const isValid = verifyWithRotation(body, ts, sig, [currentSecret, previousSecret]);

For endpoints requiring client certificate authentication, configure a client certificate and private key per endpoint. Mittr presents the certificate during the TLS handshake.

Terminal window
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-----"
}'

Restrict which IPs can receive deliveries from Mittr using per-endpoint CIDR allow/block lists.

Only deliver to IPs matching the allow list. All other IPs are blocked.

Terminal window
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 specific IP ranges. All other IPs are allowed.

Terminal window
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"]
}'

If your firewall requires allowlisting Mittr’s outbound IPs:

Terminal window
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.

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.