Webhook Signing
Mitte signs every outgoing webhook delivery with an HMAC-SHA256 signature, allowing you to verify that the request genuinely came from Mitte and has not been tampered with.
Overview
When you create an endpoint, Mitte automatically generates a signing secret in the format whsec_<random>. Every webhook delivery to your target URL includes a signature header that you can use to verify authenticity.
Signature Header
Mitte adds the following header to each signed delivery:
X-Mitte-Signature: t=1739487600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
The header contains two comma-separated values:
| Part | Description |
|---|---|
t=... | Unix timestamp (seconds) when the signature was generated. |
v1=... | HMAC-SHA256 hex digest of the signed payload. |
How Signing Works
- Mitte generates a Unix timestamp
tat the moment of delivery. - A signed payload is constructed by concatenating the timestamp, a dot (
.), and the raw request body:{timestamp}.{raw_body} - The signed payload is hashed using HMAC-SHA256 with your endpoint's signing secret as the key.
- The resulting hex digest is placed in the
X-Mitte-Signatureheader as thev1value.
Verification Steps
To verify a webhook delivery, your server should:
- Extract the
tandv1values from theX-Mitte-Signatureheader. - Reconstruct the signed payload:
{t}.{raw_request_body}. - Compute the expected HMAC-SHA256 hex digest using your signing secret.
- Compare the computed signature with the received
v1value using a timing-safe comparison to prevent timing attacks. - Check timestamp freshness — reject requests where the timestamp is older than 5 minutes to protect against replay attacks.
Code Examples
Each example below implements all five verification steps, including replay attack protection with a ±5 minute tolerance window.
Node.js
import crypto from 'crypto';
const SIGNING_SECRET = 'whsec_your_secret_here';
const TOLERANCE_SECONDS = 300; // 5 minutes
function verifyWebhook(req) {
const signature = req.headers['x-mitte-signature'];
if (!signature) {
throw new Error('Missing X-Mitte-Signature header');
}
// 1. Extract timestamp and signature
const parts = Object.fromEntries(
signature.split(',').map(p => p.split('='))
);
const timestamp = parts.t;
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) {
throw new Error('Invalid signature format');
}
// 2. Check timestamp freshness (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > TOLERANCE_SECONDS) {
throw new Error('Timestamp too old — possible replay attack');
}
// 3. Reconstruct the signed payload
const rawBody = req.body; // Must be the raw string/buffer
const signedPayload = `${timestamp}.${rawBody}`;
// 4. Compute expected signature
const expectedSig = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(signedPayload)
.digest('hex');
// 5. Timing-safe comparison
const expected = Buffer.from(expectedSig, 'utf-8');
const received = Buffer.from(receivedSig, 'utf-8');
if (expected.length !== received.length ||
!crypto.timingSafeEqual(expected, received)) {
throw new Error('Invalid signature');
}
return true; // Signature is valid
}
Python
import hmac
import hashlib
import time
SIGNING_SECRET = 'whsec_your_secret_here'
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_webhook(headers: dict, raw_body: bytes) -> bool:
signature = headers.get('X-Mitte-Signature') or headers.get('x-mitte-signature')
if not signature:
raise ValueError('Missing X-Mitte-Signature header')
# 1. Extract timestamp and signature
parts = dict(p.split('=', 1) for p in signature.split(','))
timestamp = parts.get('t')
received_sig = parts.get('v1')
if not timestamp or not received_sig:
raise ValueError('Invalid signature format')
# 2. Check timestamp freshness (replay protection)
now = int(time.time())
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
raise ValueError('Timestamp too old — possible replay attack')
# 3. Reconstruct the signed payload
signed_payload = f'{timestamp}.{raw_body.decode("utf-8")}'
# 4. Compute expected signature
expected_sig = hmac.new(
SIGNING_SECRET.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256,
).hexdigest()
# 5. Timing-safe comparison
if not hmac.compare_digest(expected_sig, received_sig):
raise ValueError('Invalid signature')
return True
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
const (
signingSecret = "whsec_your_secret_here"
toleranceSeconds = 300 // 5 minutes
)
func verifyWebhook(r *http.Request, rawBody []byte) error {
signature := r.Header.Get("X-Mitte-Signature")
if signature == "" {
return errors.New("missing X-Mitte-Signature header")
}
// 1. Extract timestamp and signature
parts := make(map[string]string)
for _, p := range strings.Split(signature, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
timestamp, ok1 := parts["t"]
receivedSig, ok2 := parts["v1"]
if !ok1 || !ok2 {
return errors.New("invalid signature format")
}
// 2. Check timestamp freshness (replay protection)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp: %w", err)
}
now := time.Now().Unix()
if math.Abs(float64(now-ts)) > toleranceSeconds {
return errors.New("timestamp too old — possible replay attack")
}
// 3. Reconstruct the signed payload
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(rawBody))
// 4. Compute expected signature
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(signedPayload))
expectedSig := hex.EncodeToString(mac.Sum(nil))
// 5. Timing-safe comparison (hmac.Equal is constant-time)
if !hmac.Equal([]byte(expectedSig), []byte(receivedSig)) {
return errors.New("invalid signature")
}
return nil // Signature is valid
}
Replay Attack Protection
The timestamp (t) embedded in the signature header serves as protection against replay attacks — where an attacker intercepts a valid webhook request and re-sends it later.
We recommend rejecting any webhook where the timestamp deviates from the current time by more than 5 minutes (300 seconds). All code examples above include this check.
A ±5 minute window accounts for reasonable clock skew between servers while still providing strong replay protection. You can tighten this window (e.g., to 60 seconds) if your server time is well-synchronized via NTP.
Additional Delivery Headers
Mitte includes additional headers with each webhook delivery:
| Header | Description |
|---|---|
X-Mitte-Signature | HMAC-SHA256 signature (t=...,v1=...). Present only if a signing secret is set. |
X-Mitte-Attempt | Current delivery attempt number (1, 2, etc.). Pro plan supports up to 5 retries with exponential backoff. |
X-Mitte-Transform | applied if a JSONata transform was successfully applied, failed if the transform errored. |
Managing Your Signing Secret
Viewing the Secret
Your signing secret is shown once when you create an endpoint. After that, you can reveal it by clicking the eye icon on the endpoint detail page in the dashboard.
Rotating the Secret
You can rotate (regenerate) your signing secret at any time:
- Dashboard: Go to your endpoint detail page → click the Rotate button next to the signing secret. Confirm the action in the dialog.
- API: Send a
PUTrequest to/api/v1/endpoints/:id/secret(requires session authentication).