API Reference
Webhook Security
This section explains why verifying webhook signatures is essential for security and how the verification process works.
Updated December 9, 2025
Why Verify Signatures?
Without signature verification, anyone who discovers your webhook URL can send fake events:
# Attacker sends fake webhook curl -X POST https://your-site.com/webhook \ -H "Content-Type: application/json" \ -d '{"event":"crawl.completed","data":{"fake":"data"}}'
Your application would process it as legitimate, potentially triggering:
- ❌ False notifications to your team
- ❌ Incorrect database updates
- ❌ Unwanted automated actions
- ❌ Business logic errors
With signature verification, only webhooks from SEO Crawler are accepted. Fake requests are rejected.
How Signature Verification Works
Every webhook includes a cryptographic signature in the X-LinkHealth-Signature header:
X-LinkHealth-Signature: t=1702123456,v1=a1b2c3d4e5f6...
Format: t=<timestamp>,v1=<signature>
- t = Unix timestamp when the webhook was sent
- v1 = HMAC-SHA256 signature of
timestamp.payload
To verify:
- Extract timestamp and signature from header
- Compute expected signature using your secret
- Compare using constant-time comparison
- Check timestamp is recent (prevents replay attacks)
Verification Examples
Node.js
const crypto = require('crypto'); function verifyWebhookSignature(payload, signatureHeader, secret) { try { // Parse signature header const parts = signatureHeader.split(','); const timestamp = parts.find(p => p.startsWith('t='))?.substring(2); const signature = parts.find(p => p.startsWith('v1='))?.substring(3); if (!timestamp || !signature) { return false; } // Check timestamp (prevent replay attacks - 5 min tolerance) const now = Math.floor(Date.now() / 1000); const age = Math.abs(now - parseInt(timestamp, 10)); if (age > 300) { console.error('Webhook timestamp too old:', age, 'seconds'); return false; } // Compute expected signature const payloadString = JSON.stringify(payload); const signedPayload = `${timestamp}.${payloadString}`; const expectedSignature = crypto .createHmac('sha256', secret) .update(signedPayload) .digest('hex'); // Constant-time comparison (prevents timing attacks) return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } catch (error) { console.error('Signature verification error:', error); return false; } } // Express middleware app.post('/webhook', express.json(), (req, res) => { const signature = req.headers['x-linkhealth-signature']; const secret = process.env.WEBHOOK_SECRET; if (!verifyWebhookSignature(req.body, signature, secret)) { return res.status(401).json({ error: 'Invalid signature' }); } // Process webhook... res.json({ received: true }); });
Python (Flask)
import hmac import hashlib import time from flask import Flask, request, jsonify app = Flask(__name__) WEBHOOK_SECRET = 'your-webhook-secret' def verify_webhook_signature(payload, signature_header, secret): try: # Parse signature header parts = {p.split('=')[0]: p.split('=')[1] for p in signature_header.split(',')} timestamp = parts.get('t') signature = parts.get('v1') if not timestamp or not signature: return False # Check timestamp (5 minute tolerance) now = int(time.time()) age = abs(now - int(timestamp)) if age > 300: print(f'Webhook timestamp too old: {age} seconds') return False # Compute expected signature signed_payload = f"{timestamp}.{payload}" expected_signature = hmac.new( secret.encode('utf-8'), signed_payload.encode('utf-8'), hashlib.sha256 ).hexdigest() # Constant-time comparison return hmac.compare_digest(signature, expected_signature) except Exception as e: print(f'Signature verification error: {e}') return False def webhook(): signature = request.headers.get('X-LinkHealth-Signature') secret = WEBHOOK_SECRET payload = request.get_data(as_text=True) if not verify_webhook_signature(payload, signature, secret): return jsonify({'error': 'Invalid signature'}), 401 # Process webhook... data = request.get_json() print(f"Received event: {data.get('event')}") return jsonify({'received': True})
PHP
function verifyWebhookSignature($payload, $signatureHeader, $secret) { try { // Parse signature header $parts = []; foreach (explode(',', $signatureHeader) as $part) { list($key, $value) = explode('=', $part, 2); $parts[$key] = $value; } $timestamp = $parts['t'] ?? null; $signature = $parts['v1'] ?? null; if (!$timestamp || !$signature) { return false; } // Check timestamp (5 minute tolerance) $now = time(); $age = abs($now - intval($timestamp)); if ($age > 300) { error_log("Webhook timestamp too old: $age seconds"); return false; } // Compute expected signature $signedPayload = "$timestamp.$payload"; $expectedSignature = hash_hmac('sha256', $signedPayload, $secret); // Constant-time comparison return hash_equals($signature, $expectedSignature); } catch (Exception $e) { error_log("Signature verification error: " . $e->getMessage()); return false; } } // Handle webhook $payload = file_get_contents('php://input'); $signatureHeader = $_SERVER['HTTP_X_LINKHEALTH_SIGNATURE'] ?? ''; $secret = getenv('WEBHOOK_SECRET'); if (!verifyWebhookSignature($payload, $signatureHeader, $secret)) { http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit; } // Process webhook... $data = json_decode($payload, true); error_log("Received event: " . $data['event']); http_response_code(200); echo json_encode(['received' => true]);
Go
package main import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "io/ioutil" "net/http" "strconv" "strings" "time" ) func verifyWebhookSignature(payload []byte, signatureHeader, secret string) bool { // Parse signature header parts := make(map[string]string) for _, part := range strings.Split(signatureHeader, ",") { kv := strings.SplitN(part, "=", 2) if len(kv) == 2 { parts[kv[0]] = kv[1] } } timestamp, ok := parts["t"] if !ok { return false } signature, ok := parts["v1"] if !ok { return false } // Check timestamp (5 minute tolerance) ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } now := time.Now().Unix() if abs(now-ts) > 300 { return false } // Compute expected signature signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload)) h := hmac.New(sha256.New, []byte(secret)) h.Write([]byte(signedPayload)) expectedSignature := hex.EncodeToString(h.Sum(nil)) // Constant-time comparison return subtle.ConstantTimeCompare( []byte(signature), []byte(expectedSignature), ) == 1 } func webhookHandler(w http.ResponseWriter, r *http.Request) { payload, _ := ioutil.ReadAll(r.Body) signature := r.Header.Get("X-LinkHealth-Signature") secret := "your-webhook-secret" if !verifyWebhookSignature(payload, signature, secret) { http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized) return } // Process webhook... var data map[string]interface{} json.Unmarshal(payload, &data) fmt.Printf("Received event: %s\n", data["event"]) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"received":true}`)) } func abs(n int64) int64 { if n < 0 { return -n } return n }
Security Best Practices
1. Always Verify Signatures
// ❌ INSECURE - Don't do this app.post('/webhook', (req, res) => { processEvent(req.body); // No verification! res.json({ received: true }); }); // ✅ SECURE - Always verify app.post('/webhook', (req, res) => { if (!verifySignature(req.body, req.headers['x-linkhealth-signature'])) { return res.status(401).json({ error: 'Invalid signature' }); } processEvent(req.body); res.json({ received: true }); });
2. Use Constant-Time Comparison
// ❌ INSECURE - Timing attack vulnerable if (signature === expectedSignature) { ... } // ✅ SECURE - Constant-time comparison if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { ... }
3. Check Timestamp Freshness
// Reject webhooks older than 5 minutes const MAX_AGE = 300; // seconds const age = Math.abs(now - timestamp); if (age > MAX_AGE) { return false; // Prevents replay attacks }
4. Store Secrets Securely
// ❌ INSECURE - Hardcoded secret const secret = 'abc123...'; // ✅ SECURE - Environment variable const secret = process.env.WEBHOOK_SECRET;
# .env file (never commit to git!) WEBHOOK_SECRET=your-actual-secret-here
5. Use Raw Request Body
// ❌ WRONG - Body already parsed/modified app.use(express.json()); app.post('/webhook', (req, res) => { verify(JSON.stringify(req.body)); // May not match original! }); // ✅ CORRECT - Verify raw body app.post('/webhook', express.json({ verify: (req, res, buf) => { req.rawBody = buf } }), (req, res) => { verify(req.rawBody.toString()); // Original body } );
Testing Signature Verification
Manual Test with curl
SECRET="your-webhook-secret" TIMESTAMP=$(date +%s) PAYLOAD='{"event":"test","data":{}}' # Generate signature SIGNED_PAYLOAD="${TIMESTAMP}.${PAYLOAD}" SIGNATURE=$(echo -n "$SIGNED_PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') # Send webhook curl -X POST https://your-domain.com/webhook \ -H "Content-Type: application/json" \ -H "X-LinkHealth-Signature: t=${TIMESTAMP},v1=${SIGNATURE}" \ -d "$PAYLOAD"
Unit Test Example (Node.js/Jest)
const crypto = require('crypto'); describe('Webhook Signature Verification', () => { const secret = 'test-secret'; function createSignature(payload, secret) { const timestamp = Math.floor(Date.now() / 1000); const payloadString = JSON.stringify(payload); const signedPayload = `${timestamp}.${payloadString}`; const signature = crypto .createHmac('sha256', secret) .update(signedPayload) .digest('hex'); return `t=${timestamp},v1=${signature}`; } test('accepts valid signature', () => { const payload = { event: 'test' }; const signature = createSignature(payload, secret); expect(verifyWebhookSignature(payload, signature, secret)).toBe(true); }); test('rejects invalid signature', () => { const payload = { event: 'test' }; const signature = 't=1234567890,v1=invalid'; expect(verifyWebhookSignature(payload, signature, secret)).toBe(false); }); test('rejects old timestamp', () => { const payload = { event: 'test' }; const oldTimestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago const signature = `t=${oldTimestamp},v1=abc123`; expect(verifyWebhookSignature(payload, signature, secret)).toBe(false); }); });
Troubleshooting
"Signature verification always fails"
Check these common issues:
- Using wrong secret - Copy the exact secret shown when creating webhook
- Body modified - Verify the raw request body, not parsed/reformatted JSON
- Wrong algorithm - Must use HMAC-SHA256, not MD5 or SHA1
- Case sensitivity - Signature is lowercase hex
- Timestamp issues - Server time must be accurate (use NTP)
"Intermittent signature failures"
Likely causes:
- Clock drift - Check server time is synchronized
- Load balancer modification - Some proxies modify request bodies
- Character encoding - Ensure UTF-8 throughout
Next Steps
Now that you understand security: