Skip to content

Webhooks

Receive real-time notifications about moderation events.

Overview

Webhooks allow Vettly to push events to your server in real-time, rather than polling for updates. This is useful for:

  • Async batch processing - Get notified when batch jobs complete
  • Audit logging - Track all moderation decisions
  • Analytics - Build dashboards with moderation data
  • Alerting - Get notified of concerning patterns

Quick Start

1. Create an Endpoint

typescript
// app/api/webhooks/vettly/route.ts
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const event = await request.json()

  console.log('Received event:', event.type)

  if (event.type === 'decision.created') {
    // Handle new moderation decision
  }

  return NextResponse.json({ received: true })
}

2. Register the Webhook

typescript
import { ModerationClient } from '@nextauralabs/vettly-sdk'

const client = new ModerationClient({ apiKey: 'your-api-key' })

const webhook = await client.registerWebhook({
  url: 'https://myapp.com/api/webhooks/vettly',
  events: ['decision.created', 'decision.flagged'],
  description: 'Production webhook'
})

console.log('Webhook ID:', webhook.id)
console.log('Secret:', webhook.secret) // Save this!

3. Verify Signatures

typescript
import crypto from 'crypto'

export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('x-vettly-signature')

  const expectedSignature = crypto
    .createHmac('sha256', process.env.VETTLY_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex')

  if (signature !== expectedSignature) {
    return new Response('Invalid signature', { status: 401 })
  }

  const event = JSON.parse(body)
  // Process event...
}

Event Types

decision.created

Fired when a moderation decision is created (content was checked).

json
{
  "type": "decision.created",
  "id": "evt_abc123",
  "timestamp": "2025-01-18T10:30:00Z",
  "data": {
    "decisionId": "dec_abc123",
    "content": "Original content",
    "contentType": "text",
    "safe": true,
    "action": "allow",
    "flagged": false,
    "categories": [
      {
        "category": "violence",
        "score": 0.05,
        "threshold": 0.7,
        "triggered": false
      }
    ],
    "policyId": "moderate",
    "provider": "openai",
    "latency": 234,
    "cost": 0.001
  }
}

decision.flagged

Fired when content is flagged (at least one category triggered).

json
{
  "type": "decision.flagged",
  "id": "evt_def456",
  "timestamp": "2025-01-18T10:31:00Z",
  "data": {
    "decisionId": "dec_def456",
    "content": "Flagged content",
    "contentType": "text",
    "safe": false,
    "action": "flag",
    "flagged": true,
    "categories": [
      {
        "category": "hate_speech",
        "score": 0.82,
        "threshold": 0.7,
        "triggered": true
      }
    ],
    "policyId": "moderate",
    "provider": "openai"
  }
}

decision.blocked

Fired when content is blocked (exceeded block threshold).

json
{
  "type": "decision.blocked",
  "id": "evt_ghi789",
  "timestamp": "2025-01-18T10:32:00Z",
  "data": {
    "decisionId": "dec_ghi789",
    "content": "Blocked content",
    "contentType": "text",
    "safe": false,
    "action": "block",
    "flagged": true,
    "categories": [
      {
        "category": "violence",
        "score": 0.95,
        "threshold": 0.7,
        "triggered": true
      }
    ],
    "policyId": "strict",
    "provider": "openai"
  }
}

policy.created

Fired when a new policy is created.

json
{
  "type": "policy.created",
  "id": "evt_jkl012",
  "timestamp": "2025-01-18T11:00:00Z",
  "data": {
    "policyId": "my_policy",
    "name": "My Custom Policy",
    "version": "1.0"
  }
}

policy.updated

Fired when a policy is updated.

json
{
  "type": "policy.updated",
  "id": "evt_mno345",
  "timestamp": "2025-01-18T11:05:00Z",
  "data": {
    "policyId": "my_policy",
    "name": "My Custom Policy",
    "version": "1.1",
    "previousVersion": "1.0"
  }
}

Signature Verification

Why Verify Signatures?

Signature verification ensures:

  • Events are from Vettly, not malicious actors
  • Events haven't been tampered with
  • Your endpoint is secure

How It Works

  1. Vettly signs each webhook with your webhook secret
  2. Signature is sent in x-vettly-signature header
  3. You verify the signature using the same secret

Implementation

Node.js / Next.js

typescript
import crypto from 'crypto'

function verifySignature(
  body: string,
  signature: string,
  secret: string
): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )
}

export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('x-vettly-signature')

  if (!signature || !verifySignature(body, signature, process.env.VETTLY_WEBHOOK_SECRET!)) {
    return new Response('Invalid signature', { status: 401 })
  }

  const event = JSON.parse(body)
  // Process event...
  return new Response('OK')
}

Express

javascript
const crypto = require('crypto')

app.post('/webhooks/vettly', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-vettly-signature']
  const body = req.body.toString()

  const expectedSignature = crypto
    .createHmac('sha256', process.env.VETTLY_WEBHOOK_SECRET)
    .update(body)
    .digest('hex')

  if (signature !== expectedSignature) {
    return res.status(401).send('Invalid signature')
  }

  const event = JSON.parse(body)
  // Process event...
  res.send('OK')
})

Python

python
import hmac
import hashlib

def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)

@app.route('/webhooks/vettly', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Vettly-Signature')
    body = request.get_data()

    if not verify_signature(body, signature, os.environ['VETTLY_WEBHOOK_SECRET']):
        return 'Invalid signature', 401

    event = request.json()
    # Process event...
    return 'OK'

Event Handlers

Process Events by Type

typescript
export async function POST(request: Request) {
  const event = await request.json()

  switch (event.type) {
    case 'decision.created':
      await handleDecisionCreated(event.data)
      break

    case 'decision.flagged':
      await handleDecisionFlagged(event.data)
      break

    case 'decision.blocked':
      await handleDecisionBlocked(event.data)
      break

    case 'policy.created':
    case 'policy.updated':
      await handlePolicyChange(event.data)
      break

    default:
      console.log('Unknown event type:', event.type)
  }

  return new Response('OK')
}

async function handleDecisionCreated(data: any) {
  // Update database with moderation result
  await db.posts.update(
    { decisionId: data.decisionId },
    {
      moderationStatus: data.safe ? 'approved' : 'pending_review',
      moderationAction: data.action
    }
  )
}

async function handleDecisionFlagged(data: any) {
  // Add to moderation queue for review
  await db.moderationQueue.create({
    decisionId: data.decisionId,
    content: data.content,
    categories: data.categories.filter(c => c.triggered),
    createdAt: new Date()
  })

  // Send alert to moderators
  await sendAlert({
    title: 'Content Flagged',
    message: `Decision ${data.decisionId} requires review`,
    categories: data.categories.filter(c => c.triggered)
  })
}

async function handleDecisionBlocked(data: any) {
  // Remove content immediately
  await db.posts.update(
    { decisionId: data.decisionId },
    { status: 'blocked', visible: false }
  )

  // Notify user
  await notifyUser(data.userId, {
    type: 'content_blocked',
    reason: data.categories.filter(c => c.triggered).map(c => c.category)
  })
}

Async Processing

typescript
// Queue events for processing
import { Queue } from 'bullmq'

const webhookQueue = new Queue('webhooks')

export async function POST(request: Request) {
  const event = await request.json()

  // Acknowledge immediately
  await webhookQueue.add('process-event', event)

  return new Response('Queued', { status: 202 })
}

// Process in background worker
const worker = new Worker('webhooks', async (job) => {
  const event = job.data

  switch (event.type) {
    case 'decision.created':
      await handleDecisionCreated(event.data)
      break
    // ... other handlers
  }
})

Managing Webhooks

Create Webhook

typescript
const webhook = await client.registerWebhook({
  url: 'https://myapp.com/api/webhooks/vettly',
  events: ['decision.created', 'decision.flagged', 'decision.blocked'],
  description: 'Production webhook'
})

// Save the secret!
process.env.VETTLY_WEBHOOK_SECRET = webhook.secret

List Webhooks

typescript
const { webhooks } = await client.listWebhooks()

webhooks.forEach(hook => {
  console.log(`${hook.id}: ${hook.url}`)
  console.log(`Events: ${hook.events.join(', ')}`)
  console.log(`Enabled: ${hook.enabled}`)
})

Update Webhook

typescript
const updated = await client.updateWebhook('wh_abc123', {
  events: ['decision.created', 'decision.flagged', 'decision.blocked'],
  enabled: true
})

Delete Webhook

typescript
await client.deleteWebhook('wh_abc123')

Test Webhook

typescript
const result = await client.testWebhook(
  'wh_abc123',
  'decision.created'
)

console.log('Test successful:', result.success)

Get Delivery Logs

typescript
const { deliveries } = await client.getWebhookDeliveries('wh_abc123', {
  limit: 50
})

deliveries.forEach(d => {
  console.log(`${d.timestamp}: ${d.status} (HTTP ${d.responseCode})`)
})

Retries and Failure Handling

Retry Logic

Vettly automatically retries failed webhook deliveries:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours

After 5 failures, the webhook is marked as failed and disabled.

Respond Quickly

Respond with 200 OK within 5 seconds:

typescript
export async function POST(request: Request) {
  const event = await request.json()

  // ✅ Queue for processing
  await queue.add(event)

  // Respond immediately
  return new Response('OK')
}

// ❌ Don't do this
export async function POST(request: Request) {
  const event = await request.json()

  // This might take too long!
  await processEvent(event)

  return new Response('OK')
}

Handle Idempotency

Webhooks may be delivered more than once. Use the event ID to deduplicate:

typescript
export async function POST(request: Request) {
  const event = await request.json()

  const existing = await db.webhookEvents.findOne({ eventId: event.id })

  if (existing) {
    return new Response('Already processed')
  }

  await db.webhookEvents.create({ eventId: event.id, processedAt: new Date() })
  await handleEvent(event)

  return new Response('OK')
}

Security Best Practices

1. Always Verify Signatures

typescript
// ❌ Don't trust without verification
export async function POST(request: Request) {
  const event = await request.json()
  await handleEvent(event) // Dangerous!
}

// ✅ Always verify
export async function POST(request: Request) {
  if (!verifySignature(...)) {
    return new Response('Unauthorized', { status: 401 })
  }
  await handleEvent(event)
}

2. Use HTTPS Only

Vettly only sends webhooks to HTTPS endpoints.

typescript
// ✅ Good
url: 'https://myapp.com/webhooks/vettly'

// ❌ Bad - will be rejected
url: 'http://myapp.com/webhooks/vettly'

3. Keep Secrets Secret

typescript
// ✅ Good
const secret = process.env.VETTLY_WEBHOOK_SECRET

// ❌ Bad
const secret = 'whsec_hardcoded_secret'

4. Rate Limit Your Endpoint

typescript
import rateLimit from 'express-rate-limit'

const limiter = rateLimit({
  windowMs: 1 * 60 * 1000, // 1 minute
  max: 100 // Max 100 requests per minute
})

app.post('/webhooks/vettly', limiter, handler)

5. Log Everything

typescript
export async function POST(request: Request) {
  const event = await request.json()

  logger.info('Webhook received', {
    eventId: event.id,
    type: event.type,
    timestamp: event.timestamp
  })

  try {
    await handleEvent(event)
    logger.info('Webhook processed', { eventId: event.id })
  } catch (error) {
    logger.error('Webhook failed', {
      eventId: event.id,
      error: error.message
    })
    throw error
  }

  return new Response('OK')
}

Testing

Local Testing with ngrok

bash
# Start ngrok
ngrok http 3000

# Register webhook with ngrok URL
https://abc123.ngrok.io/api/webhooks/vettly

Send Test Events

typescript
const client = new ModerationClient({ apiKey: 'your-api-key' })

await client.testWebhook('wh_abc123', 'decision.created')

See Also

Released under the MIT License.