Skip to main content
VertaaUX Docs
API

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:

  1. Register a webhook URL with a secret
  2. VertaaUX sends POST requests when events occur
  3. Your server verifies the signature and processes the event

Webhook Limits by Tier

TierMax Webhooks
Free0
Pro5
Agency50
Enterprise100

Free tier users cannot create webhooks. Upgrade to Pro to enable webhook notifications.

Create Webhook

POST/v1/webhooks

Register a new webhook endpoint.

Request Body

ParameterTypeRequiredDescription
urlstringrequiredThe HTTPS URL to receive webhook events. Must use HTTPS.
secretstringrequiredA secret string (16-128 characters) used to sign payloads. You will use this to verify webhook authenticity.

Response

idstring

Unique webhook identifier.

urlstring

The registered webhook URL.

created_atstring

ISO 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

GET/v1/webhooks

List all webhooks for your account.

Response

webhooksarray

List 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

DELETE/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.completed

Event type.

timestampstring

ISO 8601 timestamp when the event occurred.

dataobject

Event 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 signed
  • v1 - HMAC-SHA256 signature

Verification Steps

  1. Extract timestamp and signature from header
  2. Compute expected signature: HMAC-SHA256(secret, timestamp.payload)
  3. Compare signatures (use timing-safe comparison)
  4. 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:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 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.

  1. Use HTTPS - Webhook URLs must use HTTPS for security
  2. Respond quickly - Return 200 within 5 seconds, process asynchronously
  3. Handle duplicates - We may send the same event multiple times (use job_id for deduplication)
  4. Store secrets securely - Never commit webhook secrets to version control
  5. 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

InterfaceMethod
SDKvertaa.webhooks.verifySignature(payload, signature)

Was this page helpful?

On this page