Webhook Security
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
Section titled “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
Section titled “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
Section titled “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 bodyapp.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'); }});import hmacimport hashlibimport timeimport json
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
import ( "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
Section titled “Webhook Secrets”Each webhook has a unique secret used for signature verification. Secrets use the format:
whsec_dGhpcyBpcyBhIHNlY3JldCBrZXkgZm9yIHRlc3Q=Getting Your Secret
Section titled “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
Section titled “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
Section titled “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
Section titled “Best Practices”Respond Quickly
Section titled “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
Section titled “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
Section titled “Use the Transaction ID”Related events share a transactionId. Use it to group events that occurred as part of the same operation.
Monitoring
Section titled “Monitoring”Execution History
Section titled “Execution History”View recent webhook deliveries:
curl "https://api.tracktile.io/hooks/{id}/executions" \ -H "Authorization: Bearer api-YOUR_TOKEN_HERE"Webhook Statistics
Section titled “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"}