Documentation

Backend pipeline with Nuxt.js

Receive AI webhooks in Nuxt server routes, run custom server logic, and call back to ModelRiver: using Nitro's built-in event handling.

Overview

Nuxt 3 uses Nitro as its server engine, providing file-based server routes that are perfect for webhook handlers. ModelRiver backend pipeline webhooks integrate naturally with Nuxt's auto-import system, composables, and server middleware.

What you'll build:

  • A Nitro server route that receives AI-generated data from ModelRiver
  • Signature verification using Nuxt server middleware
  • Background processing with callback to ModelRiver
  • A Vue composable for WebSocket-based result delivery

Quick start

Install dependencies

Bash
npx nuxi@latest init my-ai-app
cd my-ai-app
npm install

Environment variables

Bash
# .env
NUXT_MODELRIVER_API_KEY=mr_live_YOUR_API_KEY
NUXT_MODELRIVER_WEBHOOK_SECRET=your_webhook_secret
TYPESCRIPT
1// nuxt.config.ts
2export default defineNuxtConfig({
3 runtimeConfig: {
4 modelriverApiKey: "",
5 modelriverWebhookSecret: "",
6 },
7});

Webhook handler

Server route

TYPESCRIPT
1// server/api/webhooks/modelriver.post.ts
2import crypto from "crypto";
3 
4function verifySignature(rawBody: string, timestamp: string, signature: string, secret: string): boolean {
5 // Signed string: "<timestamp>.<exact JSON text of the raw body's "data" value>" (never re-encode)
6 const start = rawBody.indexOf('"data":');
7 const end = rawBody.lastIndexOf(',"timestamp":');
8 if (start === -1 || end === -1) return false;
9 
10 const expected = crypto
11 .createHmac("sha256", secret)
12 .update(`${timestamp}.${rawBody.slice(start + '"data":'.length, end)}`)
13 .digest("hex");
14 
15 const a = Buffer.from(signature);
16 const b = Buffer.from(expected);
17 return a.length === b.length && crypto.timingSafeEqual(a, b);
18}
19 
20export default defineEventHandler(async (event) => {
21 const config = useRuntimeConfig();
22 const signature = getHeader(event, "x-modelriver-signature");
23 const timestamp = getHeader(event, "x-modelriver-timestamp") ?? "";
24 const rawBody = await readRawBody(event);
25 
26 // 1. Verify webhook signature
27 if (!signature || !rawBody || !verifySignature(rawBody, timestamp, signature, config.modelriverWebhookSecret)) {
28 throw createError({ statusCode: 401, statusMessage: "Invalid signature" });
29 }
30 
31 const payload = JSON.parse(rawBody).data; // the event payload is under "data"
32 const { type, event: eventName, ai_response, callback_url, customer_data } = payload;
33 
34 // 2. Handle backend pipeline workflow
35 if (type === "task.ai_generated" && callback_url) {
36 // Process and call back
37 await processAndCallback(eventName, ai_response, callback_url, customer_data, config);
38 
39 return { received: true };
40 }
41 
42 // Standard webhook
43 console.log("Standard webhook received:", payload.status);
44 return { received: true };
45});
46 
47async function processAndCallback(
48 eventName: string,
49 aiResponse: { data: Record<string, unknown> },
50 callbackUrl: string,
51 customerData: Record<string, unknown>,
52 config: ReturnType<typeof useRuntimeConfig>
53) {
54 try {
55 let enrichedData = { ...aiResponse.data };
56 
57 // 3. Your custom business logic
58 if (eventName === "content_ready") {
59 enrichedData = {
60 ...enrichedData,
61 processed: true,
62 saved_at: new Date().toISOString(),
63 };
64 }
65 
66 // 4. Call back to ModelRiver
67 await $fetch(callbackUrl, {
68 method: "POST",
69 headers: {
70 Authorization: `Bearer ${config.modelriverApiKey}`,
71 "Content-Type": "application/json",
72 },
73 body: {
74 data: enrichedData,
75 task_id: `task_${Date.now()}`,
76 },
77 });
78 
79 console.log(`✅ Callback sent for event: ${eventName}`);
80 } catch (error) {
81 console.error("Processing error:", error);
82 
83 await $fetch(callbackUrl, {
84 method: "POST",
85 headers: {
86 Authorization: `Bearer ${config.modelriverApiKey}`,
87 "Content-Type": "application/json",
88 },
89 body: {
90 data: {
91 processing_status: "failed",
92 processing_error: error instanceof Error ? error.message : "Unknown error",
93 },
94 },
95 });
96 }
97}

Triggering an async request

Server route

TYPESCRIPT
1// server/api/ai/generate.post.ts
2export default defineEventHandler(async (event) => {
3 const config = useRuntimeConfig();
4 const body = await readBody(event);
5 
6 const response = await $fetch("https://api.modelriver.com/v1/ai/async", {
7 method: "POST",
8 headers: {
9 Authorization: `Bearer ${config.modelriverApiKey}`,
10 "Content-Type": "application/json",
11 },
12 body: {
13 model: "content_generator",
14 messages: [{ role: "user", content: body.prompt }],
15 metadata: body.metadata || {},
16 },
17 });
18 
19 return response;
20});

Vue composable

TYPESCRIPT
1// composables/useEventDrivenAI.ts
2export function useEventDrivenAI() {
3 const result = ref<Record<string, unknown> | null>(null);
4 const status = ref<"idle" | "processing" | "complete" | "error">("idle");
5 
6 async function generate(prompt: string) {
7 status.value = "processing";
8 result.value = null;
9 
10 try {
11 const { channel_id, ws_token, websocket_channel } = await $fetch("/api/ai/generate", {
12 method: "POST",
13 body: { prompt },
14 });
15 
16 // Connect via WebSocket
17 const ws = new WebSocket(
18 `wss://api.modelriver.com/socket/websocket?token=${encodeURIComponent(ws_token)}`
19 );
20 
21 ws.onopen = () => {
22 ws.send(JSON.stringify({
23 topic: websocket_channel,
24 event: "phx_join",
25 payload: {},
26 ref: "1",
27 }));
28 };
29 
30 ws.onmessage = (event) => {
31 const msg = JSON.parse(event.data);
32 if (msg.event === "response") {
33 result.value = msg.payload.data;
34 status.value = "complete";
35 ws.close();
36 }
37 };
38 
39 ws.onerror = () => {
40 status.value = "error";
41 };
42 } catch (error) {
43 status.value = "error";
44 console.error("Failed to trigger AI generation:", error);
45 }
46 }
47 
48 return { result, status, generate };
49}

Page component

VUE
1<!-- pages/generate.vue -->
2<template>
3 <div class="p-8 max-w-2xl mx-auto">
4 <h1 class="text-2xl font-bold mb-4">AI Content Generator</h1>
5 
6 <div class="flex gap-2 mb-6">
7 <input
8 v-model="prompt"
9 placeholder="Describe what you need..."
10 class="flex-1 border rounded px-3 py-2"
11 />
12 <button
13 @click="generate(prompt)"
14 :disabled="status === 'processing'"
15 class="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
16 >
17 {{ status === "processing" ? "Processing..." : "Generate" }}
18 </button>
19 </div>
20 
21 <pre v-if="result" class="bg-zinc-100 rounded p-4 text-sm">{{
22 JSON.stringify(result, null, 2)
23 }}</pre>
24 </div>
25</template>
26 
27<script setup lang="ts">
28const prompt = ref("");
29const { result, status, generate } = useEventDrivenAI();
30</script>

Best practices

  1. Use useRuntimeConfig(): Never hard-code API keys; Nuxt injects them securely at runtime.
  2. Use $fetch for callbacks: Nitro's built-in fetch handles retries and error parsing automatically.
  3. Read raw body for verification: Use readRawBody(event) and HMAC <timestamp>.<data JSON text> sliced from it; never re-serialize the parsed body.
  4. Keep webhook routes simple: Offload heavy processing to separate server utilities.
  5. Use composables: Wrap WebSocket logic in reusable Vue composables.

Next steps