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
- Vettly signs each webhook with your webhook secret
- Signature is sent in
x-vettly-signatureheader - 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.secretList 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 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/vettlySend Test Events
typescript
const client = new ModerationClient({ apiKey: 'your-api-key' })
await client.testWebhook('wh_abc123', 'decision.created')See Also
- TypeScript SDK - Webhook management methods
- REST API - Webhook HTTP endpoints
- Express Integration - Express webhook handlers
- Next.js Integration - Next.js webhook routes