Documentation

Event-driven AI with Next.js

Receive AI-generated webhooks in Next.js API routes, process with custom logic, and call back to ModelRiver: complete App Router implementation.

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 --app
cd my-ai-app
npm install crypto

Environment variables

Bash
# .env.local
MODELRIVER_API_KEY=mr_live_YOUR_API_KEY
MODELRIVER_WEBHOOK_SECRET=your_webhook_secret

Webhook handler

API route

TYPESCRIPT
1// app/api/webhooks/modelriver/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import crypto from "crypto";
4 
5function verifySignature(
6 payload: string,
7 signature: string,
8 secret: string
9): boolean {
10 const expected = crypto
11 .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 signature
26 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 workflow
34 if (type === "task.ai_generated" && callback_url) {
35 // Respond immediately: process in background
36 // Note: Next.js doesn't support true background tasks in serverless,
37 // so we use waitUntil or process before responding
38 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 event
58 if (event === "content_ready") {
59 // Save to database
60 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 API
71 await postToExternalService(aiResponse.data);
72 enrichedData.posted = true;
73 }
74 
75 // 4. Call back to ModelRiver with enriched data
76 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 callback
101 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 functions
116async 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 call
127 console.log("Posting to external service:", data);
128}

Triggering an async request

Server action

TYPESCRIPT
1// app/actions/ai.ts
2"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.tsx
2"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 result
17 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.ts
2export const runtime = "edge";
3 
4// ... same handler code as above, using Web Crypto API instead of Node crypto

Note: On Edge, use crypto.subtle.importKey and crypto.subtle.sign instead of Node's crypto module for HMAC verification.


Best practices

  1. Respond quickly: Return 200 before processing heavy logic. Use Vercel's waitUntil for background work in serverless environments.
  2. Verify every webhook: Always validate the mr-signature header.
  3. Use server actions for triggering: Keep API keys server-side.
  4. Handle timeouts: Your callback must reach ModelRiver within 5 minutes.
  5. Log processing steps: Use structured logging for debugging event-driven flows.

Next steps