Webhook Security
Ensure your webhook endpoints are secure and verify that requests actually come from tagd-ai.
Why Security Matters
Without verification:
- Anyone could send fake events to your endpoint
- Attackers could trigger unauthorized actions
- Data could be compromised
Signature Verification
How It Works
- You provide a secret when creating the webhook
- tagd-ai signs every payload with your secret
- You verify the signature before processing
- Invalid signatures are rejected
Signature Header
Each webhook request includes:
X-tagd-ai-Signature: sha256=abc123...
The signature is an HMAC-SHA256 hash of the request body using your secret.
Verifying Signatures
Node.js
const crypto = require('crypto');
const WEBHOOK_SECRET = process.env.TAGD_WEBHOOK_SECRET;
function verifySignature(payload, signature) {
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
const provided = signature.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(provided)
);
}
// Express middleware
function webhookMiddleware(req, res, next) {
const signature = req.headers['x-tagd-signature'];
const payload = JSON.stringify(req.body);
if (!signature) {
return res.status(401).send('Missing signature');
}
if (!verifySignature(payload, signature)) {
return res.status(401).send('Invalid signature');
}
next();
}
app.post('/webhook', webhookMiddleware, (req, res) => {
// Signature verified, safe to process
handleEvent(req.body);
res.sendStatus(200);
});
Python
import hmac
import hashlib
import os
WEBHOOK_SECRET = os.environ.get('TAGD_WEBHOOK_SECRET')
def verify_signature(payload, signature):
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-tagd-ai-Signature', '')
if not verify_signature(request.data, signature):
abort(401, 'Invalid signature')
# Signature verified, safe to process
handle_event(request.json)
return '', 200
Ruby
require 'openssl'
WEBHOOK_SECRET = ENV['TAGD_WEBHOOK_SECRET']
def verify_signature(payload, signature)
expected = 'sha256=' + OpenSSL::HMAC.hexdigest(
'sha256',
WEBHOOK_SECRET,
payload
)
Rack::Utils.secure_compare(expected, signature)
end
post '/webhook' do
signature = request.env['HTTP_X_TAGD_SIGNATURE']
payload = request.body.read
halt 401, 'Invalid signature' unless verify_signature(payload, signature)
# Signature verified, safe to process
handle_event(JSON.parse(payload))
status 200
end
PHP
<?php
$webhookSecret = getenv('TAGD_WEBHOOK_SECRET');
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_TAGD_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $payload, $webhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
// Signature verified, safe to process
$event = json_decode($payload, true);
handleEvent($event);
http_response_code(200);
Secret Management
Generating a Strong Secret
# Generate random secret
openssl rand -hex 32
Or in your language:
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
Storing Secrets
DO:
- Use environment variables
- Use secret management services (AWS Secrets Manager, etc.)
- Rotate secrets periodically
DON'T:
- Hardcode in source code
- Commit to version control
- Log or display secrets
Rotating Secrets
- Generate new secret
- Update webhook with new secret
- Update your application
- Old secret stops working immediately
Additional Security Measures
IP Allowlisting
tagd-ai sends webhooks from known IPs:
52.xxx.xxx.xxx
54.xxx.xxx.xxx
Configure your firewall to only accept from these IPs.
To get current IPs:
curl https://api.tagd-ai.com/v1/webhooks/ips
HTTPS Only
- Webhooks only sent to HTTPS endpoints
- Ensures encryption in transit
- Protects payload confidentiality
Request Timeouts
tagd-ai waits 30 seconds for response:
- Respond quickly (< 5 seconds ideal)
- Long processing = retries
- Retries could cause duplicates
Idempotency
Process each event only once:
- Store processed event IDs
- Check before processing
- Skip if already processed
const processedEvents = new Set();
function handleWebhook(event) {
if (processedEvents.has(event.id)) {
console.log('Duplicate event, skipping');
return;
}
processedEvents.add(event.id);
processEvent(event);
}
Timestamp Validation
Prevent replay attacks:
function validateTimestamp(timestamp) {
const eventTime = new Date(timestamp);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;
return Math.abs(now - eventTime) < fiveMinutes;
}
app.post('/webhook', (req, res) => {
const { created_at } = req.body;
if (!validateTimestamp(created_at)) {
return res.status(400).send('Event too old');
}
// Process event
});
Common Vulnerabilities
Missing Signature Check
Vulnerable:
app.post('/webhook', (req, res) => {
// No signature verification!
handleEvent(req.body);
res.sendStatus(200);
});
Secure:
app.post('/webhook', verifySignature, (req, res) => {
handleEvent(req.body);
res.sendStatus(200);
});
Timing Attacks
Vulnerable:
if (signature === expected) { ... }
Secure:
if (crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)) { ... }
Raw Body Parsing
Ensure you're verifying the raw body:
// Express: Use raw body for verification
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const rawBody = req.body; // Buffer
const signature = req.headers['x-tagd-signature'];
if (!verifySignature(rawBody, signature)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
handleEvent(event);
res.sendStatus(200);
});
Monitoring and Alerts
Log All Requests
app.post('/webhook', (req, res) => {
console.log({
timestamp: new Date().toISOString(),
signature_valid: verifySignature(req.body, req.headers['x-tagd-signature']),
event_type: req.body.event,
event_id: req.body.id
});
// ... rest of handler
});
Alert on Failures
Notify when:
- Multiple signature failures
- Unknown event types
- Processing errors