Overview
Next.js API routes (App Router) provide the ideal handler for ModelRiver event-driven AI webhooks. You receive structured AI output, run server-side logic, and call back: all within the same Next.js application that serves your frontend.
What you'll build:
- A webhook endpoint that receives AI-generated data from ModelRiver
- Signature verification middleware for secure webhook handling
- Background processing with callback to ModelRiver
- A React frontend that receives the final result via WebSocket
Quick start
Install dependencies
Bash
npx create-next-app@latest my-ai-app --typescript --appcd my-ai-appnpm install cryptoEnvironment variables
Bash
# .env.localMODELRIVER_API_KEY=mr_live_YOUR_API_KEYMODELRIVER_WEBHOOK_SECRET=your_webhook_secretWebhook handler
API route
TYPESCRIPT
1// app/api/webhooks/modelriver/route.ts2import { NextRequest, NextResponse } from "next/server";3import crypto from "crypto";4 5function verifySignature(6 payload: string,7 signature: string,8 secret: string9): boolean {10 const expected = crypto11 .createHmac("sha256", secret)12 .update(payload)13 .digest("hex");14 15 return crypto.timingSafeEqual(16 Buffer.from(signature),17 Buffer.from(expected)18 );19}20 21export async function POST(request: NextRequest) {22 const signature = request.headers.get("mr-signature");23 const rawBody = await request.text();24 25 // 1. Verify webhook signature26 if (!signature || !verifySignature(rawBody, signature, process.env.MODELRIVER_WEBHOOK_SECRET!)) {27 return NextResponse.json({ error: "Invalid signature" }, { status: 401 });28 }29 30 const payload = JSON.parse(rawBody);31 const { type, event, ai_response, callback_url, customer_data } = payload;32 33 // 2. Check if this is an event-driven workflow34 if (type === "task.ai_generated" && callback_url) {35 // Respond immediately: process in background36 // Note: Next.js doesn't support true background tasks in serverless,37 // so we use waitUntil or process before responding38 await processAndCallback(event, ai_response, callback_url, customer_data);39 40 return NextResponse.json({ received: true });41 }42 43 // Standard webhook (no event name)44 console.log("Standard webhook:", payload);45 return NextResponse.json({ received: true });46}47 48async function processAndCallback(49 event: string,50 aiResponse: { data: Record<string, unknown> },51 callbackUrl: string,52 customerData: Record<string, unknown>53) {54 try {55 let enrichedData = { ...aiResponse.data };56 57 // 3. Your custom business logic based on the event58 if (event === "content_ready") {59 // Save to database60 const savedRecord = await saveToDatabase(aiResponse.data);61 enrichedData = {62 ...enrichedData,63 id: savedRecord.id,64 slug: generateSlug(aiResponse.data.title as string),65 saved_at: new Date().toISOString(),66 };67 }68 69 if (event === "review_complete") {70 // Post to external API71 await postToExternalService(aiResponse.data);72 enrichedData.posted = true;73 }74 75 // 4. Call back to ModelRiver with enriched data76 const response = await fetch(callbackUrl, {77 method: "POST",78 headers: {79 "Authorization": `Bearer ${process.env.MODELRIVER_API_KEY}`,80 "Content-Type": "application/json",81 },82 body: JSON.stringify({83 data: enrichedData,84 task_id: `task_${Date.now()}`,85 metadata: {86 processed_at: new Date().toISOString(),87 event,88 },89 }),90 });91 92 if (!response.ok) {93 throw new Error(`Callback failed: ${response.status}`);94 }95 96 console.log(`✅ Callback sent for event: ${event}`);97 } catch (error) {98 console.error("Error processing webhook:", error);99 100 // Send error callback101 await fetch(callbackUrl, {102 method: "POST",103 headers: {104 "Authorization": `Bearer ${process.env.MODELRIVER_API_KEY}`,105 "Content-Type": "application/json",106 },107 body: JSON.stringify({108 error: "processing_failed",109 message: error instanceof Error ? error.message : "Unknown error",110 }),111 });112 }113}114 115// Your business logic functions116async function saveToDatabase(data: Record<string, unknown>) {117 // Replace with your actual database logic (Prisma, Drizzle, etc.)118 return { id: `rec_${Date.now()}`, ...data };119}120 121function generateSlug(title: string): string {122 return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");123}124 125async function postToExternalService(data: Record<string, unknown>) {126 // Replace with your actual API call127 console.log("Posting to external service:", data);128}Triggering an async request
Server action
TYPESCRIPT
1// app/actions/ai.ts2"use server";3 4export async function triggerContentGeneration(prompt: string) {5 const response = await fetch("https://api.modelriver.com/v1/ai/async", {6 method: "POST",7 headers: {8 "Authorization": `Bearer ${process.env.MODELRIVER_API_KEY}`,9 "Content-Type": "application/json",10 },11 body: JSON.stringify({12 workflow: "content_generator",13 messages: [{ role: "user", content: prompt }],14 }),15 });16 17 const data = await response.json();18 return {19 channelId: data.channel_id,20 wsToken: data.ws_token,21 websocketUrl: data.websocket_url,22 websocketChannel: data.websocket_channel,23 };24}React component with WebSocket
TSX
1// app/components/AiContentGenerator.tsx2"use client";3 4import { useState, useEffect } from "react";5import { triggerContentGeneration } from "@/app/actions/ai";6 7export function AiContentGenerator() {8 const [result, setResult] = useState<Record<string, unknown> | null>(null);9 const [status, setStatus] = useState<"idle" | "processing" | "complete">("idle");10 11 async function handleGenerate(prompt: string) {12 setStatus("processing");13 14 const { channelId, wsToken, websocketChannel } = await triggerContentGeneration(prompt);15 16 // Connect to WebSocket for real-time result17 const ws = new WebSocket(18 `wss://api.modelriver.com/socket/websocket?token=${encodeURIComponent(wsToken)}`19 );20 21 ws.onopen = () => {22 ws.send(JSON.stringify({23 topic: websocketChannel,24 event: "phx_join",25 payload: {},26 ref: "1",27 }));28 };29 30 ws.onmessage = (event) => {31 const msg = JSON.parse(event.data);32 33 if (msg.event === "response") {34 setResult(msg.payload.data);35 setStatus("complete");36 ws.close();37 }38 };39 }40 41 return (42 <div>43 <button onClick={() => handleGenerate("Generate product description")}>44 {status === "processing" ? "Processing..." : "Generate"}45 </button>46 {result && <pre>{JSON.stringify(result, null, 2)}</pre>}47 </div>48 );49}Edge runtime
For Vercel Edge Functions, the webhook handler works the same way: just add the edge runtime export:
TYPESCRIPT
1// app/api/webhooks/modelriver/route.ts2export const runtime = "edge";3 4// ... same handler code as above, using Web Crypto API instead of Node cryptoNote: On Edge, use
crypto.subtle.importKeyandcrypto.subtle.signinstead of Node'scryptomodule for HMAC verification.
Best practices
- Respond quickly: Return
200before processing heavy logic. Use Vercel'swaitUntilfor background work in serverless environments. - Verify every webhook: Always validate the
mr-signatureheader. - Use server actions for triggering: Keep API keys server-side.
- Handle timeouts: Your callback must reach ModelRiver within 5 minutes.
- Log processing steps: Use structured logging for debugging event-driven flows.
Next steps
- Nuxt.js event-driven guide: Vue.js alternative
- Webhooks reference: Signature verification and retry policies
- Next.js integration: Standard ModelRiver + Next.js usage