Skip to content

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-sdk

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 http

Use 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_key

Image 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