Webhooks
Configure webhooks and verify signatures for real-time notifications
Webhooks
Webhooks notify your application when events occur, such as audit completion. Instead of polling for results, receive a POST request to your endpoint.
Overview
Webhook flow:
- Register a webhook URL with a secret
- VertaaUX sends POST requests when events occur
- Your server verifies the signature and processes the event
Webhook Limits by Tier
| Tier | Max Webhooks |
|---|---|
| Free | 0 |
| Pro | 5 |
| Agency | 50 |
| Enterprise | 100 |
Free tier users cannot create webhooks. Upgrade to Pro to enable webhook notifications.
Create Webhook
/v1/webhooksRegister a new webhook endpoint.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | required | The HTTPS URL to receive webhook events. Must use HTTPS. |
| secret | string | required | A secret string (16-128 characters) used to sign payloads. You will use this to verify webhook authenticity. |
Response
idstringUnique webhook identifier.
urlstringThe registered webhook URL.
created_atstringISO 8601 timestamp when the webhook was created.
Example
import 'dotenv/config';
import crypto from 'crypto';
interface WebhookResponse {
id: string;
url: string;
created_at: string;
}
async function createWebhook(url: string, secret: string): Promise<WebhookResponse> {
const response = await fetch('https://vertaaux.ai/api/v1/webhooks', {
method: 'POST',
headers: {
'X-API-Key': process.env.VERTAAUX_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, secret }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to create webhook: ${error.message}`);
}
return response.json();
}
// Generate a secure secret
const secret = crypto.randomBytes(32).toString('hex');
// Usage
const webhook = await createWebhook('https://example.com/webhooks/vertaa', secret);
console.log(`Webhook created: ${webhook.id}`);
// Store the secret securely for signature verification# Generate a secure secret
SECRET=$(openssl rand -hex 32)
echo "Store this secret: $SECRET"
# Create webhook
curl -X POST https://vertaaux.ai/api/v1/webhooks \
-H "X-API-Key: $VERTAAUX_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"url\": \"https://example.com/webhooks/vertaa\",
\"secret\": \"$SECRET\"
}"import os
import secrets
import requests
from typing import TypedDict
class WebhookResponse(TypedDict):
id: str
url: str
created_at: str
def create_webhook(url: str, secret: str) -> WebhookResponse:
"""Create a new webhook."""
response = requests.post(
'https://vertaaux.ai/api/v1/webhooks',
headers={
'X-API-Key': os.environ['VERTAAUX_API_KEY'],
'Content-Type': 'application/json',
},
json={'url': url, 'secret': secret}
)
if not response.ok:
error = response.json()
raise Exception(f"Failed to create webhook: {error.get('message')}")
return response.json()
# Generate a secure secret
secret = secrets.token_hex(32)
print(f"Store this secret: {secret}")
# Usage
webhook = create_webhook('https://example.com/webhooks/vertaa', secret)
print(f"Webhook created: {webhook['id']}")List Webhooks
/v1/webhooksList all webhooks for your account.
Response
webhooksarrayList of registered webhooks.
curl https://vertaaux.ai/api/v1/webhooks \
-H "X-API-Key: $VERTAAUX_API_KEY"Response:
{
"webhooks": [
{
"id": "wh_abc123",
"url": "https://example.com/webhooks/vertaa",
"created_at": "2024-01-15T10:30:00Z"
}
]
}Delete Webhook
/v1/webhooks/{id}Remove a webhook endpoint.
curl -X DELETE "https://vertaaux.ai/api/v1/webhooks/wh_abc123" \
-H "X-API-Key: $VERTAAUX_API_KEY"Response: 204 No Content
Webhook Events
audit.completed
Sent when an audit finishes successfully.
eventstringenum: audit.completedEvent type.
timestampstringISO 8601 timestamp when the event occurred.
dataobjectEvent payload.
Example payload:
{
"event": "audit.completed",
"timestamp": "2024-01-15T10:31:15Z",
"data": {
"job_id": "clu1a2b3c4d5e6f7g8h9i0j1",
"url": "https://example.com",
"mode": "standard",
"scores": {
"overall": 85,
"ux": 88,
"accessibility": 82,
"information_architecture": 86,
"performance": 84
},
"total_issues": 12,
"completed_at": "2024-01-15T10:31:15Z"
}
}audit.failed
Sent when an audit fails.
Example payload:
{
"event": "audit.failed",
"timestamp": "2024-01-15T10:31:15Z",
"data": {
"job_id": "clu1a2b3c4d5e6f7g8h9i0j1",
"url": "https://example.com",
"mode": "standard",
"error": "Page timed out after 30000ms",
"failed_at": "2024-01-15T10:31:15Z"
}
}Signature Verification
All webhook payloads are signed using HMAC-SHA256. Always verify signatures to ensure payloads are from VertaaUX.
Signature Format
The signature is sent in the X-Vertaa-Signature header:
X-Vertaa-Signature: t=1705312275,v1=abc123def456...t- Unix timestamp when the payload was signedv1- HMAC-SHA256 signature
Verification Steps
- Extract timestamp and signature from header
- Compute expected signature:
HMAC-SHA256(secret, timestamp.payload) - Compare signatures (use timing-safe comparison)
- Reject if timestamp is more than 5 minutes old (replay attack protection)
Verification Code
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300; // 5 minutes
interface VerificationResult {
valid: boolean;
error?: string;
}
function verifyWebhookSignature(
payload: string,
signature: string
): VerificationResult {
// Parse signature header
const parts = signature.split(',');
const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
const sig = parts.find(p => p.startsWith('v1='))?.slice(3);
if (!timestamp || !sig) {
return { valid: false, error: 'Invalid signature format' };
}
// Check timestamp (replay attack protection)
const signedAt = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - signedAt) > TOLERANCE_SECONDS) {
return { valid: false, error: 'Signature expired' };
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// Timing-safe comparison
const sigBuffer = Buffer.from(sig, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
if (sigBuffer.length !== expectedBuffer.length) {
return { valid: false, error: 'Signature mismatch' };
}
if (!crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
return { valid: false, error: 'Signature mismatch' };
}
return { valid: true };
}
// Express.js example
import express from 'express';
const app = express();
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks/vertaa', (req, res) => {
const signature = req.headers['x-vertaa-signature'] as string;
const payload = req.body.toString();
const { valid, error } = verifyWebhookSignature(payload, signature);
if (!valid) {
console.error('Webhook verification failed:', error);
return res.status(401).json({ error });
}
const event = JSON.parse(payload);
console.log(`Received ${event.event} for job ${event.data.job_id}`);
// Process the event...
res.status(200).json({ received: true });
});import os
import hmac
import hashlib
import time
from typing import Tuple
WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_webhook_signature(
payload: str,
signature: str
) -> Tuple[bool, str | None]:
"""Verify webhook signature.
Returns:
(valid, error_message)
"""
# Parse signature header
parts = dict(p.split('=', 1) for p in signature.split(',') if '=' in p)
timestamp = parts.get('t')
sig = parts.get('v1')
if not timestamp or not sig:
return False, 'Invalid signature format'
# Check timestamp (replay attack protection)
signed_at = int(timestamp)
now = int(time.time())
if abs(now - signed_at) > TOLERANCE_SECONDS:
return False, 'Signature expired'
# Compute expected signature
signed_payload = f'{timestamp}.{payload}'
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Timing-safe comparison
if not hmac.compare_digest(sig, expected):
return False, 'Signature mismatch'
return True, None
# Flask example
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/vertaa', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Vertaa-Signature', '')
payload = request.get_data(as_text=True)
valid, error = verify_webhook_signature(payload, signature)
if not valid:
print(f'Webhook verification failed: {error}')
return jsonify({'error': error}), 401
event = request.get_json()
print(f"Received {event['event']} for job {event['data']['job_id']}")
# Process the event...
return jsonify({'received': True})Retry Policy
If your webhook endpoint returns a non-2xx status code, we retry with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
After 5 failed attempts, the webhook delivery is marked as failed and logged in your dashboard.
Best Practices
Always verify signatures. Without verification, an attacker could send fake events to your endpoint.
- Use HTTPS - Webhook URLs must use HTTPS for security
- Respond quickly - Return 200 within 5 seconds, process asynchronously
- Handle duplicates - We may send the same event multiple times (use
job_idfor deduplication) - Store secrets securely - Never commit webhook secrets to version control
- Monitor failures - Check your dashboard for failed deliveries
Troubleshooting
401 Unauthorized
- Check your API key is valid
- Verify you're on Pro tier or higher
403 Webhook Limit Reached
- Delete unused webhooks or upgrade your tier
Signature Verification Fails
- Ensure you're using the raw request body (not parsed JSON)
- Check the secret matches what you registered
- Verify timestamp tolerance (default 5 minutes)
Also Available
| Interface | Method |
|---|---|
| SDK | vertaa.webhooks.verifySignature(payload, signature) |
Related
Was this page helpful?