Overview
Convex is a reactive database platform where queries automatically update when data changes. Combine it with ModelRiver backend pipeline and you get an end-to-end reactive pipeline: AI generates data → webhook writes to Convex → your React components update instantly.
What you'll build:
- A Convex HTTP action that receives ModelRiver webhooks
- A mutation for writing AI-generated data
- Real-time queries that update the UI automatically
- Callback to ModelRiver with record details
Schema
TYPESCRIPT
1// convex/schema.ts2import { defineSchema, defineTable } from "convex/server";3import { v } from "convex/values";4 5export default defineSchema({6 aiContent: defineTable({7 title: v.string(),8 body: v.optional(v.string()),9 category: v.string(),10 metadata: v.any(),11 channelId: v.optional(v.string()),12 eventName: v.optional(v.string()),13 source: v.string(),14 })15 .index("by_channel", ["channelId"])16 .index("by_event", ["eventName"])17 .index("by_creation", ["_creationTime"]),18});Mutation
TYPESCRIPT
1// convex/aiContent.ts2import { mutation, query } from "./_generated/server";3import { v } from "convex/values";4 5export const insert = mutation({6 args: {7 title: v.string(),8 body: v.optional(v.string()),9 category: v.string(),10 metadata: v.any(),11 channelId: v.optional(v.string()),12 eventName: v.optional(v.string()),13 },14 handler: async (ctx, args) => {15 const id = await ctx.db.insert("aiContent", {16 ...args,17 source: "modelriver",18 });19 return id;20 },21});22 23export const list = query({24 args: {25 limit: v.optional(v.number()),26 },27 handler: async (ctx, args) => {28 return await ctx.db29 .query("aiContent")30 .order("desc")31 .take(args.limit ?? 20);32 },33});34 35export const getByChannel = query({36 args: {37 channelId: v.string(),38 },39 handler: async (ctx, args) => {40 return await ctx.db41 .query("aiContent")42 .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))43 .first();44 },45});HTTP action (webhook handler)
TYPESCRIPT
1// convex/http.ts2import { httpRouter } from "convex/server";3import { httpAction } from "./_generated/server";4import { api } from "./_generated/api";5 6const http = httpRouter();7 8http.route({9 path: "/webhooks/modelriver",10 method: "POST",11 handler: httpAction(async (ctx, request) => {12 const signature = request.headers.get("x-modelriver-signature") ?? "";13 const timestamp = request.headers.get("x-modelriver-timestamp") ?? "";14 const rawBody = await request.text();15 16 // 1. Verify signature: HMAC-SHA256 of "<timestamp>.<raw JSON text of body.data>"17 const start = rawBody.indexOf('"data":') + '"data":'.length;18 const end = rawBody.lastIndexOf(',"timestamp":');19 const webhookSecret = process.env.MODELRIVER_WEBHOOK_SECRET ?? "";20 const encoder = new TextEncoder();21 const key = await crypto.subtle.importKey(22 "raw",23 encoder.encode(webhookSecret),24 { name: "HMAC", hash: "SHA-256" },25 false,26 ["sign"]27 );28 const sig = await crypto.subtle.sign(29 "HMAC",30 key,31 encoder.encode(`${timestamp}.${rawBody.slice(start, end)}`)32 );33 const expected = Array.from(new Uint8Array(sig))34 .map((b) => b.toString(16).padStart(2, "0"))35 .join("");36 37 // Constant-time comparison38 let diff = expected.length ^ signature.length;39 for (let i = 0; i < expected.length; i++) {40 diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);41 }42 43 if (start < 7 || end < start || diff !== 0) {44 return new Response(JSON.stringify({ error: "Invalid signature" }), {45 status: 401,46 headers: { "Content-Type": "application/json" },47 });48 }49 50 const payload = JSON.parse(rawBody).data; // the event payload is under body.data51 const { type, event, ai_response, callback_url, customer_data, channel_id } = payload;52 53 // 2. Handle backend pipeline workflow54 if (type === "task.ai_generated" && callback_url) {55 try {56 const aiData = ai_response?.data ?? {};57 58 // 3. Write to Convex database via mutation59 const recordId = await ctx.runMutation(api.aiContent.insert, {60 title: aiData.title ?? "Untitled",61 body: aiData.description ?? aiData.body ?? "",62 category: customer_data?.category ?? "general",63 metadata: {64 ...customer_data,65 ai_model: ai_response?.meta?.used_model,66 ai_provider: ai_response?.meta?.used_provider,67 },68 channelId: channel_id,69 eventName: event,70 });71 72 // 4. Call back to ModelRiver73 const callbackResponse = await fetch(callback_url, {74 method: "POST",75 headers: {76 Authorization: `Bearer ${process.env.MODELRIVER_API_KEY}`,77 "Content-Type": "application/json",78 },79 body: JSON.stringify({80 data: {81 ...aiData,82 id: recordId,83 saved_at: new Date().toISOString(),84 },85 task_id: `convex_${recordId}`,86 metadata: {87 database: "convex",88 table: "aiContent",89 record_id: recordId,90 },91 }),92 });93 94 if (!callbackResponse.ok) {95 throw new Error(`Callback failed: ${callbackResponse.status}`);96 }97 98 return new Response(JSON.stringify({ received: true }), {99 status: 200,100 headers: { "Content-Type": "application/json" },101 });102 103 } catch (error: any) {104 console.error("Error:", error);105 106 await fetch(callback_url, {107 method: "POST",108 headers: {109 Authorization: `Bearer ${process.env.MODELRIVER_API_KEY}`,110 "Content-Type": "application/json",111 },112 body: JSON.stringify({113 data: {114 processing_status: "failed",115 processing_error: error.message,116 },117 }),118 });119 120 return new Response(JSON.stringify({ received: true }), {121 status: 200,122 headers: { "Content-Type": "application/json" },123 });124 }125 }126 127 return new Response(JSON.stringify({ received: true }), {128 status: 200,129 headers: { "Content-Type": "application/json" },130 });131 }),132});133 134export default http;React frontend (real-time)
Convex queries are reactive: the UI updates automatically when the webhook writes new data:
TSX
1// src/App.tsx2import { useQuery } from "convex/react";3import { api } from "../convex/_generated/api";4 5export function AiContentFeed() {6 // This query automatically updates when new records are inserted!7 const items = useQuery(api.aiContent.list, { limit: 10 });8 9 if (items === undefined) return <p>Loading...</p>;10 11 return (12 <div className="space-y-4">13 {items.map((item) => (14 <div key={item._id} className="p-4 border rounded-lg">15 <h3 className="font-bold text-lg">{item.title}</h3>16 <p className="text-zinc-600">{item.body}</p>17 <div className="flex gap-2 mt-2">18 <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">19 {item.category}20 </span>21 <span className="text-xs text-zinc-400">22 {new Date(item._creationTime).toLocaleString()}23 </span>24 </div>25 </div>26 ))}27 </div>28 );29}No WebSocket setup, no polling, no subscriptions: Convex handles reactivity automatically.
Best practices
- Use HTTP actions for webhooks: Convex HTTP actions can receive external HTTP requests.
- Use mutations for writes: All database writes go through validated mutations.
- Leverage reactive queries: No need for separate WebSocket/polling infrastructure.
- Use Web Crypto API: Convex HTTP actions run in a V8 environment with built-in Web Crypto.
- Store environment variables: Use Convex dashboard settings for API keys and secrets.
Next steps
- Back to Serverless Databases: All database guides
- Backend pipeline overview: Architecture and flow
- Webhooks reference: Retry policies and delivery monitoring