Overview
Next.js API routes (App Router) provide the ideal handler for ModelRiver backend pipeline 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 rawBody: string,7 timestamp: string,8 signature: string,9 secret: string10): boolean {11 // Signed string: "<timestamp>.<exact JSON text of the raw body's "data" value>" (never re-encode)12 const start = rawBody.indexOf('"data":');13 const end = rawBody.lastIndexOf(',"timestamp":');14 if (start === -1 || end === -1) return false;15 16 const expected = crypto17 .createHmac("sha256", secret)18 .update(`${timestamp}.${rawBody.slice(start + '"data":'.length, end)}`)19 .digest("hex");20 21 const a = Buffer.from(signature);22 const b = Buffer.from(expected);23 return a.length === b.length && crypto.timingSafeEqual(a, b);24}25 26export async function POST(request: NextRequest) {27 const signature = request.headers.get("x-modelriver-signature");28 const timestamp = request.headers.get("x-modelriver-timestamp") ?? "";29 const rawBody = await request.text(); // raw body: needed for signature verification30 31 // 1. Verify webhook signature32 if (!signature || !verifySignature(rawBody, timestamp, signature, process.env.MODELRIVER_WEBHOOK_SECRET!)) {33 return NextResponse.json({ error: "Invalid signature" }, { status: 401 });34 }35 36 const payload = JSON.parse(rawBody).data; // the event payload is under "data"37 const { type, event, ai_response, callback_url, customer_data } = payload;38 39 // 2. Check if this is a backend pipeline workflow40 if (type === "task.ai_generated" && callback_url) {41 // Respond immediately: process in background42 // Note: Next.js doesn't support true background tasks in serverless,43 // so we use waitUntil or process before responding44 await processAndCallback(event, ai_response, callback_url, customer_data);45 46 return NextResponse.json({ received: true });47 }48 49 // Standard webhook (no event name)50 console.log("Standard webhook:", payload);51 return NextResponse.json({ received: true });52}53 54async function processAndCallback(55 event: string,56 aiResponse: { data: Record<string, unknown> },57 callbackUrl: string,58 customerData: Record<string, unknown>59) {60 try {61 let enrichedData = { ...aiResponse.data };62 63 // 3. Your custom business logic based on the event64 if (event === "content_ready") {65 // Save to database66 const savedRecord = await saveToDatabase(aiResponse.data);67 enrichedData = {68 ...enrichedData,69 id: savedRecord.id,70 slug: generateSlug(aiResponse.data.title as string),71 saved_at: new Date().toISOString(),72 };73 }74 75 if (event === "review_complete") {76 // Post to external API77 await postToExternalService(aiResponse.data);78 enrichedData.posted = true;79 }80 81 // 4. Call back to ModelRiver with enriched data82 const response = await fetch(callbackUrl, {83 method: "POST",84 headers: {85 "Authorization": `Bearer ${process.env.MODELRIVER_API_KEY}`,86 "Content-Type": "application/json",87 },88 body: JSON.stringify({89 data: enrichedData,90 task_id: `task_${Date.now()}`,91 metadata: {92 processed_at: new Date().toISOString(),93 event,94 },95 }),96 });97 98 if (!response.ok) {99 throw new Error(`Callback failed: ${response.status}`);100 }101 102 console.log(`✅ Callback sent for event: ${event}`);103 } catch (error) {104 console.error("Error processing webhook:", error);105 106 // Send error callback107 await fetch(callbackUrl, {108 method: "POST",109 headers: {110 "Authorization": `Bearer ${process.env.MODELRIVER_API_KEY}`,111 "Content-Type": "application/json",112 },113 body: JSON.stringify({114 data: {115 processing_status: "failed",116 processing_error: error instanceof Error ? error.message : "Unknown error",117 },118 }),119 });120 }121}122 123// Your business logic functions124async function saveToDatabase(data: Record<string, unknown>) {125 // Replace with your actual database logic (Prisma, Drizzle, etc.)126 return { id: `rec_${Date.now()}`, ...data };127}128 129function generateSlug(title: string): string {130 return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");131}132 133async function postToExternalService(data: Record<string, unknown>) {134 // Replace with your actual API call135 console.log("Posting to external service:", data);136}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 model: "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. Sign the same<timestamp>.<data JSON text>string, then hex-encode the result.
Best practices
- Respond quickly: Return
200before processing heavy logic. Use Vercel'swaitUntilfor background work in serverless environments. - Verify every webhook: Always validate the
x-modelriver-signatureheader. - Use server actions for triggering: Keep API keys server-side.
- Handle timeouts: Your callback must reach ModelRiver within 10 minutes.
- Log processing steps: Use structured logging for debugging backend pipeline flows.
Next steps
- Nuxt.js backend pipeline guide: Vue.js alternative
- Webhooks reference: Signature verification and retry policies
- Next.js integration: Standard ModelRiver + Next.js usage