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-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(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 = crypto11 .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 signature27 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 workflow35 if (type === "task.ai_generated" && callback_url) {36 // Process and call back37 await processAndCallback(eventName, ai_response, callback_url, customer_data, config);38 39 return { received: true };40 }41 42 // Standard webhook43 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 logic58 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 ModelRiver67 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.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 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.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-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
- 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)and HMAC<timestamp>.<data JSON text>sliced from it; never re-serialize the parsed body. - Keep webhook routes simple: Offload heavy processing to separate server utilities.
- Use composables: Wrap WebSocket logic in reusable Vue composables.
Next steps
- Django backend pipeline guide: Python alternative
- Webhooks reference: Retry policies and delivery monitoring
- Backend pipeline overview: Architecture and flow