Documentation

Event-driven AI 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 event-driven 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(payload: string, signature: string, secret: string): boolean {
5 const expected = crypto
6 .createHmac("sha256", secret)
7 .update(payload)
8 .digest("hex");
9 
10 return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
11}
12 
13export default defineEventHandler(async (event) => {
14 const config = useRuntimeConfig();
15 const signature = getHeader(event, "mr-signature");
16 const rawBody = await readRawBody(event);
17 
18 // 1. Verify webhook signature
19 if (!signature || !rawBody || !verifySignature(rawBody, signature, config.modelriverWebhookSecret)) {
20 throw createError({ statusCode: 401, statusMessage: "Invalid signature" });
21 }
22 
23 const payload = JSON.parse(rawBody);
24 const { type, event: eventName, ai_response, callback_url, customer_data } = payload;
25 
26 // 2. Handle event-driven workflow
27 if (type === "task.ai_generated" && callback_url) {
28 // Process and call back
29 await processAndCallback(eventName, ai_response, callback_url, customer_data, config);
30 
31 return { received: true };
32 }
33 
34 // Standard webhook
35 console.log("Standard webhook received:", payload.type);
36 return { received: true };
37});
38 
39async function processAndCallback(
40 eventName: string,
41 aiResponse: { data: Record<string, unknown> },
42 callbackUrl: string,
43 customerData: Record<string, unknown>,
44 config: ReturnType<typeof useRuntimeConfig>
45) {
46 try {
47 let enrichedData = { ...aiResponse.data };
48 
49 // 3. Your custom business logic
50 if (eventName === "content_ready") {
51 enrichedData = {
52 ...enrichedData,
53 processed: true,
54 saved_at: new Date().toISOString(),
55 };
56 }
57 
58 // 4. Call back to ModelRiver
59 await $fetch(callbackUrl, {
60 method: "POST",
61 headers: {
62 Authorization: `Bearer ${config.modelriverApiKey}`,
63 "Content-Type": "application/json",
64 },
65 body: {
66 data: enrichedData,
67 task_id: `task_${Date.now()}`,
68 },
69 });
70 
71 console.log(`✅ Callback sent for event: ${eventName}`);
72 } catch (error) {
73 console.error("Processing error:", error);
74 
75 await $fetch(callbackUrl, {
76 method: "POST",
77 headers: {
78 Authorization: `Bearer ${config.modelriverApiKey}`,
79 "Content-Type": "application/json",
80 },
81 body: {
82 error: "processing_failed",
83 message: error instanceof Error ? error.message : "Unknown error",
84 },
85 });
86 }
87}

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 workflow: "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-gray-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) to get the unparsed payload for HMAC verification.
  4. Keep webhook routes simple: Offload heavy processing to separate server utilities.
  5. Use composables: Wrap WebSocket logic in reusable Vue composables.

Next steps