Documentation

Backend pipeline architecture

Build reactive, asynchronous AI pipelines. Decouple AI generation from delivery with high-performance webhook callbacks and real-time WebSocket streaming.

What is a backend pipeline?

A backend pipeline decouples AI generation from response delivery. Instead of waiting synchronously for an AI response, you fire an async request and let ModelRiver notify your backend when the result is ready. Your backend then runs custom logic: database writes, API calls, enrichment, validation: and calls back to ModelRiver, which delivers the final result to your frontend via WebSocket.

The three-step flow:

  1. AI generates: Your app calls POST /v1/ai/async with a workflow that has an event_name. ModelRiver processes the AI request in the background.
  2. Your backend processes: ModelRiver delivers the AI result to your webhook endpoint. Your code executes custom business logic (save to database, call external APIs, validate, enrich).
  3. ModelRiver delivers: Your backend calls the callback_url with the enriched data. ModelRiver broadcasts the final result to connected WebSocket clients in real time.

For multi-step pipelines (up to 6 events with optional linked workflows between backend steps), see Multi-step pipelines.


Why use a backend pipeline?

BenefitDescription
Non-blockingYour frontend never waits for AI + backend processing. Users see instant "processing" states.
Custom logic before deliveryValidate AI output, enrich with database data, trigger side effects: all before the user sees the result.
Reliable deliveryModelRiver retries failed webhook deliveries automatically (up to 3 attempts) and times out pipelines whose callback never arrives.
ObservableEvery step is logged in the Timeline: AI request, webhook delivery, backend callback.
ScalableProcess thousands of concurrent AI requests without blocking web servers.

How it works

┌──────────┐ POST /v1/ai/async ┌──────────────┐
│ Your │ ──────────────────────────▶ │ ModelRiver │
│ App │ { workflow, messages } │ (AI engine) │
│ │ ◀────────────────────────── │ │
│ │ { channel_id, ws_token } │ │
└──────────┘ └──────┬───────┘
│ │
│ Connect WebSocket │ AI processes
│ (ai_response:{project}:{channel}) │ in background
│ ▼
│ ┌──────────────┐
│ │ Webhook │
│ │ delivery │
│ └──────┬───────┘
│ │
│ ▼
│ ┌──────────────┐
│ │ Your │
│ │ Backend │
│ │ (webhook) │
│ └──────┬───────┘
│ │
│ │ POST callback_url
│ │ { data, task_id }
│ ▼
│ ┌──────────────┐
│ WebSocket push │ ModelRiver │
│ ◀──────────────────────────────── │ (callback) │
│ { status: "completed", data } └──────────────┘
▼
┌──────────┐
│ Frontend │ Renders final result
└──────────┘

Quick start

1. Create a workflow with an event name

In the ModelRiver console, create a workflow and set the Event name field. This tells ModelRiver to use the backend pipeline flow instead of returning the AI response directly.

Create workflow event

2. Send an async request

Bash
curl -X POST https://api.modelriver.com/v1/ai/async \
-H "Authorization: Bearer mr_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"model": "content_generator",
"messages": [
{"role": "user", "content": "Generate a product description for wireless headphones"}
],
"metadata": {
"product_id": "prod_123",
"category": "electronics"
}
}'

Response:

JSON
1{
2 "message": "success",
3 "status": "pending",
4 "channel_id": "550e8400-e29b-41d4-a716-446655440000",
5 "ws_token": "AbC123XyZ...one-time-token",
6 "websocket_url": "wss://api.modelriver.com/socket",
7 "websocket_channel": "ai_response:proj_xyz:550e8400-e29b-41d4-a716-446655440000"
8}

3. Receive the webhook

ModelRiver sends the AI result to your registered webhook endpoint:

JSON
1{
2 "callback_url": "https://api.modelriver.com/v1/callback/550e8400-e29b-41d4-a716-446655440000",
3 "channel_id": "550e8400-e29b-41d4-a716-446655440000",
4 "data": {
5 "type": "task.ai_generated",
6 "event": "content_ready",
7 "channel_id": "550e8400-e29b-41d4-a716-446655440000",
8 "step_index": 0,
9 "total_steps": 1,
10 "step_type": "backend",
11 "pipeline": {
12 "workflow_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
13 "workflow_name": "content_generator",
14 "step_name": "content_ready"
15 },
16 "ai_response": {
17 "status": "pending",
18 "data": {
19 "title": "ProSound X1 Wireless Headphones",
20 "description": "Experience crystal-clear audio...",
21 "features": ["Active noise cancellation", "40-hour battery"]
22 },
23 "meta": {
24 "used_provider": "openai",
25 "used_model": "gpt-4o"
26 }
27 },
28 "callback_url": "https://api.modelriver.com/v1/callback/550e8400-e29b-41d4-a716-446655440000",
29 "callback_required": true,
30 "customer_data": {
31 "metadata.product_id": "prod_123",
32 "metadata.category": "electronics"
33 }
34 },
35 "timestamp": 1771144200
36}

The event payload is under data. The customer_data keys are the field paths configured on the workflow.

4. Process and call back

Your backend processes the AI output and calls the callback_url:

JAVASCRIPT
1// POST {callback_url}
2await fetch(callbackUrl, {
3 method: "POST",
4 headers: {
5 "Authorization": `Bearer ${process.env.MODELRIVER_API_KEY}`,
6 "Content-Type": "application/json",
7 },
8 body: JSON.stringify({
9 data: {
10 ...aiResponse.data,
11 slug: generateSlug(aiResponse.data.title),
12 seo_keywords: await generateKeywords(aiResponse.data),
13 saved_at: new Date().toISOString(),
14 },
15 task_id: `product_${productId}`,
16 }),
17});

5. Frontend receives the final result

The connected WebSocket client receives the enriched response:

JSON
1{
2 "status": "completed",
3 "data": {
4 "title": "ProSound X1 Wireless Headphones",
5 "description": "Experience crystal-clear audio...",
6 "features": ["Active noise cancellation", "40-hour battery"],
7 "slug": "prosound-x1-wireless-headphones",
8 "seo_keywords": ["wireless headphones", "noise cancelling"],
9 "saved_at": "2026-02-15T08:30:02.000Z"
10 },
11 "customer_data": {
12 "metadata.product_id": "prod_123",
13 "metadata.category": "electronics"
14 }
15}

Webhook payload reference

Headers

HeaderDescription
content-typeapplication/json
x-modelriver-signatureHMAC-SHA256 signature (lowercase hex) for verification
x-modelriver-timestampUnix timestamp (seconds) of the delivery attempt
x-modelriver-webhook-idWebhook endpoint ID

Envelope fields

FieldTypeDescription
callback_urlstringURL to POST your processed result back to ModelRiver
channel_idstringUnique request identifier
dataobjectThe event payload (fields below)
timestampintegerUnix timestamp (seconds)

Event payload fields (data)

FieldTypeDescription
typestringAlways "task.ai_generated" for backend pipeline workflows
eventstringYour custom event name (the current step's name)
channel_idstringUnique request identifier
step_indexintegerZero-based index of the current pipeline step
total_stepsintegerNumber of steps in the pipeline
step_typestring"backend" for a step your backend handles
pipelineobjectworkflow_id, workflow_name, and step_name
ai_responseobjectThe AI-generated data in ai_response.data, with status: "pending" and the provider/model details in meta
callback_urlstringURL to POST your processed result back to ModelRiver
callback_requiredbooleantrue: ModelRiver is waiting for your callback
customer_dataobjectCached fields from your request, keyed by field path

Callback API

After processing the AI response, POST your enriched data to the callback_url.

Headers:

  • Authorization: Bearer {your_api_key}
  • Content-Type: application/json

Success payload:

JSON
1{
2 "data": {
3 "your_enriched_fields": "..."
4 },
5 "task_id": "optional_tracking_id",
6 "metadata": {
7 "processing_time_ms": 234
8 }
9}

data must be a JSON object. ModelRiver merges it over the AI output, so your fields are added to (or replace) the AI fields of the same name, and the merged result is what your frontend receives. task_id and metadata are passed through to the final WebSocket message as task_id and callback_metadata.

Reporting a failure: The callback has no separate error mode. Top-level fields such as error or message are ignored, and the callback is treated as a success: the pipeline continues, and your frontend receives the final result with status: "completed". To tell your frontend that backend processing failed, put the failure inside data and handle it in your UI:

JSON
1{
2 "data": {
3 "processing_status": "failed",
4 "processing_error": "Database connection timeout"
5 }
6}

Callback responses:

StatuserrorWhen
200(none)Callback accepted. A repeated callback for a step that was already received returns 200 again without reprocessing it.
400invalid_payloaddata is not a JSON object (for example, an array or a string)
400invalid_channel_idThe channel ID in the URL is not a valid UUID
401missing_api_keyNo API key was provided
403forbiddenThe API key belongs to a different project than the request
404channel_not_foundThe channel doesn't exist, has already completed, or has timed out
409callback_in_progressAnother callback for the same step is still being processed
409step_index_mismatchThe callback is for a pipeline step that has already advanced
410channel_expiredThe callback arrived after the channel expired

Timeout: If your backend doesn't call back within 10 minutes, ModelRiver sends a callback_timeout error to the WebSocket channel and records the backend callback as timed out in the request's timeline. The window restarts each time a multi-step pipeline advances to a new step. Callbacks sent after the timeout are rejected.


Signature verification

Always verify the x-modelriver-signature header to ensure webhook payloads are genuinely from ModelRiver. The signature is the hex HMAC-SHA256, keyed with your webhook secret, of <x-modelriver-timestamp>.<data>, where <data> is the exact JSON text of the body's data value. Take that text from the raw request body (after the first "data": and before the last ,"timestamp":). Don't parse the body and re-serialize data: re-encoding changes number formatting and escaping, so the signature won't match.

JAVASCRIPT
1const crypto = require("crypto");
2 
3// rawBody: the unparsed request body as a string (e.g. from express.raw())
4function verifySignature(rawBody, timestamp, signature, secret) {
5 const start = rawBody.indexOf('"data":') + '"data":'.length;
6 const end = rawBody.lastIndexOf(',"timestamp":');
7 if (start < 7 || end < start) return false;
8 
9 const expected = crypto
10 .createHmac("sha256", secret)
11 .update(`${timestamp}.${rawBody.slice(start, end)}`)
12 .digest("hex");
13 
14 return (
15 signature.length === expected.length &&
16 crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
17 );
18}

Also reject deliveries whose x-modelriver-timestamp is more than about 5 minutes old. Every retry gets a fresh timestamp and signature, so this doesn't block legitimate retries.


Use cases

Use caseEvent nameWhat your backend does
Content generationcontent_readySave to CMS, generate SEO metadata, create thumbnails
Code reviewreview_completePost comments to GitHub PR, update ticket status
Data extractionentities_extractedValidate against schema, write to database, trigger downstream workflows
Customer supportticket_classifiedRoute to correct team, update CRM, send notification
Document processingsummary_generatedStore in knowledge base, index for search, notify stakeholders

Backend framework guides

Step-by-step guides for implementing backend pipeline in your framework:

FrameworkLanguageGuide
Next.jsTypeScriptView guide →
Nuxt.jsTypeScriptView guide →
DjangoPythonView guide →
FastAPIPythonView guide →
LaravelPHPView guide →
RailsRubyView guide →
PhoenixElixirView guide →
Spring BootJavaView guide →
.NETC#View guide →

Serverless database guides

Use backend pipeline with serverless databases to build fully reactive data pipelines:


Next steps