Convex + Vettly
Add content moderation to your Convex app.
Overview
Convex's real-time backend pairs perfectly with Vettly's moderation API. This guide covers:
- Moderating in mutations before storing
- HTTP actions for moderation
- Async moderation with scheduled functions
Installation
bash
npm install @nextauralabs/vettly-sdkOption 1: HTTP Action (Recommended)
Use an HTTP action to call Vettly from your Convex backend.
Create the HTTP Action
ts
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
const http = httpRouter()
http.route({
path: '/moderate',
method: 'POST',
handler: httpAction(async (ctx, request) => {
const { content, contentType = 'text' } = await request.json()
const response = await fetch('https://api.vettly.dev/v1/check', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VETTLY_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content, contentType })
})
const result = await response.json()
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' }
})
})
})
export default httpUse in Your App
ts
// In your React component
async function sendMessage(content: string) {
// 1. Moderate first
const modResponse = await fetch(`${CONVEX_URL}/moderate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, contentType: 'text' })
})
const modResult = await modResponse.json()
if (modResult.action === 'block') {
throw new Error('Message not allowed')
}
// 2. Store in Convex
await mutation(api.messages.send, {
content,
moderationStatus: modResult.action,
moderationId: modResult.decisionId
})
}Option 2: Action with fetch
Call Vettly directly from a Convex action.
ts
// convex/moderation.ts
import { action } from './_generated/server'
import { v } from 'convex/values'
export const checkContent = action({
args: {
content: v.string(),
contentType: v.optional(v.string())
},
handler: async (ctx, { content, contentType = 'text' }) => {
const response = await fetch('https://api.vettly.dev/v1/check', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VETTLY_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content, contentType })
})
if (!response.ok) {
throw new Error('Moderation check failed')
}
return await response.json()
}
})Combine with Mutation
ts
// convex/messages.ts
import { mutation, action } from './_generated/server'
import { v } from 'convex/values'
import { api } from './_generated/api'
export const sendModerated = action({
args: {
content: v.string(),
channelId: v.id('channels')
},
handler: async (ctx, { content, channelId }) => {
// 1. Check moderation
const modResult = await ctx.runAction(api.moderation.checkContent, {
content,
contentType: 'text'
})
if (modResult.action === 'block') {
return { success: false, error: 'Content blocked' }
}
// 2. Store message
await ctx.runMutation(api.messages.create, {
content,
channelId,
moderationStatus: modResult.action,
moderationId: modResult.decisionId
})
return { success: true }
}
})
export const create = mutation({
args: {
content: v.string(),
channelId: v.id('channels'),
moderationStatus: v.string(),
moderationId: v.string()
},
handler: async (ctx, args) => {
return await ctx.db.insert('messages', {
...args,
createdAt: Date.now()
})
}
})Option 3: Async Moderation
Moderate after storing, useful for non-blocking UX.
ts
// convex/messages.ts
import { mutation, action } from './_generated/server'
import { v } from 'convex/values'
// Store immediately with pending status
export const send = mutation({
args: {
content: v.string(),
channelId: v.id('channels')
},
handler: async (ctx, { content, channelId }) => {
const messageId = await ctx.db.insert('messages', {
content,
channelId,
moderationStatus: 'pending',
createdAt: Date.now()
})
// Schedule moderation check
await ctx.scheduler.runAfter(0, api.moderation.checkMessage, {
messageId
})
return messageId
}
})
// convex/moderation.ts
export const checkMessage = action({
args: { messageId: v.id('messages') },
handler: async (ctx, { messageId }) => {
// Get the message
const message = await ctx.runQuery(api.messages.get, { messageId })
if (!message) return
// Call Vettly
const response = await fetch('https://api.vettly.dev/v1/check', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VETTLY_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
content: message.content,
contentType: 'text'
})
})
const result = await response.json()
// Update the message
await ctx.runMutation(api.messages.updateModeration, {
messageId,
status: result.action,
moderationId: result.decisionId
})
}
})Schema
ts
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
export default defineSchema({
messages: defineTable({
content: v.string(),
channelId: v.id('channels'),
userId: v.id('users'),
moderationStatus: v.string(), // 'pending' | 'allow' | 'block' | 'flag'
moderationId: v.optional(v.string()),
createdAt: v.number()
})
.index('by_channel', ['channelId'])
.index('by_moderation', ['moderationStatus']),
channels: defineTable({
name: v.string()
})
})Filtering Content
Only show allowed messages:
ts
// convex/messages.ts
export const listAllowed = query({
args: { channelId: v.id('channels') },
handler: async (ctx, { channelId }) => {
return await ctx.db
.query('messages')
.withIndex('by_channel', q => q.eq('channelId', channelId))
.filter(q => q.neq(q.field('moderationStatus'), 'block'))
.order('desc')
.take(50)
}
})Environment Variables
Set your Vettly API key in Convex:
bash
npx convex env set VETTLY_API_KEY vettly_your_api_keyImage Moderation
ts
// convex/moderation.ts
export const checkImage = action({
args: { storageId: v.id('_storage') },
handler: async (ctx, { storageId }) => {
// Get the image URL
const url = await ctx.storage.getUrl(storageId)
if (!url) throw new Error('Image not found')
// Fetch and convert to base64
const response = await fetch(url)
const buffer = await response.arrayBuffer()
const base64 = Buffer.from(buffer).toString('base64')
// Moderate
const modResponse = await fetch('https://api.vettly.dev/v1/check', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VETTLY_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
content: base64,
contentType: 'image'
})
})
return await modResponse.json()
}
})Real-time Updates
Convex's reactivity means moderation status updates automatically:
tsx
// React component
function Messages({ channelId }) {
// Automatically updates when moderation status changes
const messages = useQuery(api.messages.listAllowed, { channelId })
return (
<div>
{messages?.map(msg => (
<Message key={msg._id} message={msg} />
))}
</div>
)
}Next Steps
- Custom Policies - Configure moderation rules
- Best Practices - Moderation patterns
- API Reference - Full API documentation