Webhook Security
Verify webhook signatures and secure your endpoints
Tracktile signs all webhook payloads with HMAC-SHA256, allowing you to verify that events genuinely came from Tracktile and haven't been tampered with.
Signature Verification
Every webhook request includes a signature in the X-Tracktile-Signature header:
X-Tracktile-Signature: t=1699900000,v1=5a3c8e9f...
t=Unix timestamp (seconds) when the webhook was sentv1=HMAC-SHA256 signature (hex-encoded)
Verification Steps
- Extract the timestamp (
t) and signature (v1) from the header - Read the raw request body (before JSON parsing)
- Compute:
HMAC-SHA256(secret, "{timestamp}.{rawBody}") - Compare your computed signature to
v1using constant-time comparison - Verify the timestamp is within your tolerance (recommended: 5 minutes)
Code Examples
const crypto = require('crypto');
function verifyWebhook(req, secret, toleranceSec = 300) {
const sig = req.headers['x-tracktile-signature'];
const [tPart, v1Part] = sig.split(',');
const timestamp = parseInt(tPart.replace('t=', ''), 10);
const signature = v1Part.replace('v1=', '');
// Check timestamp freshness (prevents replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSec) {
throw new Error('Webhook timestamp too old');
}
// Verify signature
const payload = `${timestamp}.${req.rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
throw new Error('Invalid webhook signature');
}
return JSON.parse(req.rawBody);
}
// Express middleware to capture raw body
app.use('/webhook', express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
app.post('/webhook', (req, res) => {
try {
const event = verifyWebhook(req, process.env.WEBHOOK_SECRET);
// Process verified event
res.status(200).send('OK');
} catch (err) {
res.status(401).send('Invalid signature');
}
});
def verify_webhook(headers, body, secret, tolerance_sec=300):
sig_header = headers.get('X-Tracktile-Signature', '')
parts = dict(p.split('=', 1) for p in sig_header.split(','))
timestamp = int(parts['t'])
signature = parts['v1']
# Check timestamp freshness (prevents replay attacks)
if abs(time.time() - timestamp) > tolerance_sec:
raise ValueError('Webhook timestamp too old')
# Verify signature
payload = f"{timestamp}.{body}"
expected = hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError('Invalid webhook signature')
return json.loads(body)
# Flask example
@app.route('/webhook', methods=['POST'])
def handle_webhook():
try:
body = request.get_data(as_text=True)
event = verify_webhook(request.headers, body, WEBHOOK_SECRET)
# Process verified event
return 'OK', 200
except ValueError as e:
return str(e), 401package main
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func verifyWebhook(r *http.Request, secret string, toleranceSec int64) ([]byte, error) {
sig := r.Header.Get("X-Tracktile-Signature")
parts := strings.Split(sig, ",")
var timestamp int64
var signature string
for _, part := range parts {
if strings.HasPrefix(part, "t=") {
timestamp, _ = strconv.ParseInt(strings.TrimPrefix(part, "t="), 10, 64)
} else if strings.HasPrefix(part, "v1=") {
signature = strings.TrimPrefix(part, "v1=")
}
}
// Check timestamp freshness
now := time.Now().Unix()
if math.Abs(float64(now-timestamp)) > float64(toleranceSec) {
return nil, fmt.Errorf("webhook timestamp too old")
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
// Verify signature
payload := fmt.Sprintf("%d.%s", timestamp, string(body))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expected)) {
return nil, fmt.Errorf("invalid webhook signature")
}
return body, nil
}
Webhook Secrets
Each webhook has a unique secret used for signature verification. Secrets use the format:
whsec_dGhpcyBpcyBhIHNlY3JldCBrZXkgZm9yIHRlc3Q=
Getting Your Secret
The webhook secret is returned only once when you create a webhook:
curl -X POST "https://api.tracktile.io/hooks" \
-H "Authorization: Bearer api-YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"name": "My Webhook",
"url": "https://your-server.com/webhook",
"on": "order.status.changed"
}'Response:
{
"id": "whk_abc123",
"name": "My Webhook",
"url": "https://your-server.com/webhook",
"on": "order.status.changed",
"status": "active",
"secret": "whsec_dGhpcyBpcyBhIHNlY3JldC4uLg==",
"secretLastRotatedAt": "2026-02-03T12:00:00Z"
}Rotating Secrets
If your secret is compromised, rotate it immediately:
curl -X POST "https://api.tracktile.io/hooks/{id}/rotate" \
-H "Authorization: Bearer api-YOUR_TOKEN_HERE"Response:
{
"secret": "whsec_bmV3IHNlY3JldCBnZW5lcmF0ZWQ=",
"secretLastRotatedAt": "2026-02-03T14:30:00Z"
}The old secret is immediately invalidated. Update your server with the new secret right away.
Additional Headers
Tracktile includes additional headers with each webhook request:
| Header | Description |
|---|---|
X-Tracktile-Signature | Signature for verification (t=...,v1=...) |
X-Tracktile-Webhook-Id | The webhook configuration ID |
Content-Type | Always application/json |
Best Practices
Respond Quickly
- Return a 2xx response within 30 seconds
- Process events asynchronously if your logic is complex
app.post("/webhook", (req, res) => {
const event = verifyWebhook(req, secret);
// Acknowledge immediately
res.status(200).send("OK");
// Process asynchronously
processEventAsync(event);
});Handle Duplicates
Webhooks may be delivered more than once. Use the event id to deduplicate:
app.post("/webhook", async (req, res) => {
const event = verifyWebhook(req, secret);
if (await isEventProcessed(event.id)) {
return res.status(200).send("Already processed");
}
await processEvent(event);
await markEventProcessed(event.id);
res.status(200).send("OK");
});Use the Transaction ID
Related events share a transactionId. Use it to group events that occurred as part of the same operation.
Monitoring
Execution History
View recent webhook deliveries:
curl "https://api.tracktile.io/hooks/{id}/executions" \
-H "Authorization: Bearer api-YOUR_TOKEN_HERE"Webhook Statistics
Each webhook tracks delivery metrics:
{
"id": "whk_abc123",
"name": "My Webhook",
"status": "active",
"successCount": 150,
"errorCount": 3,
"lastRunAt": "2026-02-03T10:30:00Z"
}