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-appcd my-ai-appnpm installEnvironment variables
Bash
# .envNUXT_MODELRIVER_API_KEY=mr_live_YOUR_API_KEYNUXT_MODELRIVER_WEBHOOK_SECRET=your_webhook_secretTYPESCRIPT
1// nuxt.config.ts2export default defineNuxtConfig({3 runtimeConfig: {4 modelriverApiKey: "",5 modelriverWebhookSecret: "",6 },7});Webhook handler
Server route
TYPESCRIPT
1// server/api/webhooks/modelriver.post.ts2import crypto from "crypto";3 4function verifySignature(payload: string, signature: string, secret: string): boolean {5 const expected = crypto6 .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 signature19 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 workflow27 if (type === "task.ai_generated" && callback_url) {28 // Process and call back29 await processAndCallback(eventName, ai_response, callback_url, customer_data, config);30 31 return { received: true };32 }33 34 // Standard webhook35 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 logic50 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 ModelRiver59 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.ts2export 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.ts2export 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 WebSocket17 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 <input8 v-model="prompt"9 placeholder="Describe what you need..."10 class="flex-1 border rounded px-3 py-2"11 />12 <button13 @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
- Use
useRuntimeConfig(): Never hard-code API keys; Nuxt injects them securely at runtime. - Use
$fetchfor callbacks: Nitro's built-in fetch handles retries and error parsing automatically. - Read raw body for verification: Use
readRawBody(event)to get the unparsed payload for HMAC verification. - Keep webhook routes simple: Offload heavy processing to separate server utilities.
- Use composables: Wrap WebSocket logic in reusable Vue composables.
Next steps
- Django event-driven guide: Python alternative
- Webhooks reference: Retry policies and delivery monitoring
- Event-driven AI overview: Architecture and flow