DEV Community

Cover image for Building a Multi-Engine 3D Generation API: Routing, Credits, and Webhooks
Trify3D
Trify3D

Posted on

Building a Multi-Engine 3D Generation API: Routing, Credits, and Webhooks

How I designed the API layer for Trify3D — a platform that routes one input across multiple AI 3D engines (Tripo3D, Meshy, Rodin) so users can compare meshes side by side. This post covers provider routing, async job management with Trigger.dev, idempotency for credit safety, and webhook delivery.


The Problem

Every AI 3D engine has a blind spot.

Tripo3D is fast (~48 seconds) and great at hard-surface props, but it flattens organic detail. Meshy handles characters and creatures more cleanly (~76 seconds), but its topology gets messy on hard surfaces. Rodin produces the highest-fidelity PBR textures (~90 seconds), but it's the slowest and most expensive.

If a user picks one engine, they're stuck with its weaknesses. To compare results, they'd need three separate accounts, three subscriptions, and three credit pools — then manually juggle browser tabs.

I built Trify3D to solve this: one input, every engine, one credit pool. A user uploads an image or writes a prompt, the platform routes it to multiple 3D AI engines simultaneously, and they compare the meshes side by side before exporting the winner.

This post is about the API layer that makes that work.


Architecture Overview

Here's the high-level flow:

Client Request
    │
    ▼
┌──────────────────┐
│  API Gateway     │  Bearer auth, rate limit, idempotency check
│  (trify3d.com)   │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Provider Router  │  Routes to Tripo3D / Meshy / Rodin
│  (mode + model)  │  based on mode + model prefix
└────────┬─────────┘
         │
    ┌────┼────┐
    ▼    ▼    ▼
┌──────┐┌──────┐┌──────┐
│Tripo3D││Meshy ││Rodin │  Async generation
└──┬───┘└──┬───┘└──┬───┘
   │       │       │
   └───────┼───────┘
           ▼
┌──────────────────┐
│  Trigger.dev     │  Job orchestration, retries, 10-min timeout
│  (async runner)  │
└────────┬─────────┘
         │
    ┌────┴────┐
    ▼         ▼
┌────────┐ ┌──────────┐
│  Poll  │ │  Webhook  │  Client picks one or both
│ (GET)  │ │ (POST)    │
└────────┘ └──────────┘
Enter fullscreen mode Exit fullscreen mode

Three decisions drove this architecture:

  1. Async-first — 3D generation takes 48–90 seconds. No HTTP request should hang that long.
  2. Provider-agnostic API — clients shouldn't need to know engine-specific SDKs.
  3. Credit safety — retries happen. Credits must never be double-charged.

Let me walk through each piece.


API Design

Base Setup

Base URL:     https://trify3d.com/api/v1
Auth:         Bearer token (trf_live_…)
Format:       JSON request / response
Rate limit:   600 requests / hour / key (rolling window)
Enter fullscreen mode Exit fullscreen mode

Three generation endpoints, one polling endpoint:

Endpoint Method Purpose
/generations/text-to-3d POST Generate from a text prompt
/generations/image-to-3d POST Generate from a reference image
/generations/multiview-to-3d POST Reconstruct from 2+ photos
/generations/{taskId} GET Poll task status

Response Envelope

Every response — success or error — follows the same shape:

// Success (2xx)
{
  "ok": true,
  "data": { /* payload */ },
  "requestId": "req_abc123"
}

// Error (4xx / 5xx)
{
  "ok": false,
  "error": {
    "code": "snake_case_code",
    "message": "Human-readable message.",
    "requestId": "req_abc123",
    "details": { /* optional context */ }
  }
}
Enter fullscreen mode Exit fullscreen mode

The requestId appears in an X-Request-ID header too. When a user reports an issue, one ID traces the entire request lifecycle. This has saved me hours of debugging.


Provider Routing

This was the most interesting design problem. The API needs to route to the right engine based on what the client wants — but without forcing the client to learn each engine's quirks.

The routing logic lives in a simple rule chain:

function resolveProvider(body: GenerationRequest): Provider {
  // Explicit model id always wins
  if (body.model?.startsWith("rodin/")) return "rodin";
  if (body.model?.startsWith("tripo/")) return "tripo3d";

  // Multipart flag forces Rodin's multi-part pipeline
  if (body.multiPart === true) return "rodin";

  // Speed mode → Tripo3D (fastest engine, ~48s)
  if (body.mode === "speed") return "tripo3d";

  // Default → Meshy (balanced for quality)
  return "meshy";
}
Enter fullscreen mode Exit fullscreen mode

That's it. No ML-based routing, no A/B test framework. A deterministic rule chain that any developer can read and predict.

Why not auto-route with AI?

I considered training a router model that picks the best engine per input. But:

  • Predictability > cleverness — developers building on an API need to know which engine will run. Non-deterministic routing breaks trust.
  • The user should choose — that's the whole point of the platform. The "compare" mode runs all three engines on the same input and lets the user pick.

Engine benchmarks (real data)

These numbers come from our provider config, not marketing materials:

Engine Runtime (median) Credit multiplier Best at
Tripo3D ~48s 1.0x Hard-surface props, weapons, vehicles
Meshy ~76s 1.3x Characters, creatures, organic shapes
Rodin ~90s 1.5x High-fidelity PBR for final assets

A full "compare" pass runs all three, costing roughly 3.8x credits total (1.0 + 1.3 + 1.5). For a throwaway prototype prop, that's wasteful. For a hero asset you'll ship, the comparison is worth it.


Async Jobs with Trigger.dev

3D generation is slow. Holding an HTTP connection open for 90 seconds is a recipe for timeouts, load balancer issues, and frustrated users.

I use Trigger.dev as the async job runner. Here's why:

The status lifecycle

pending → processing → completed | failed
Enter fullscreen mode Exit fullscreen mode

When a client POSTs a generation request:

  1. API creates a task record (status: pending)
  2. Hands off to Trigger.dev (status: processing)
  3. Trigger.dev calls the provider API, polls until done
  4. On success → downloads the model, uploads to CDN, sets status: completed
  5. On failure → sets status: failed with an errorCode

The client either polls GET /generations/{taskId} or receives a webhook. Their choice.

Why Trigger.dev?

  • Built-in retries — provider APIs fail. Transient errors shouldn't surface to the user.
  • 10-minute max duration — Rodin's quality mode can take 90+ seconds; the timeout headroom is generous.
  • Observability — every job has a dashboard entry I can inspect when something goes wrong.

Polling example

curl https://trify3d.com/api/v1/generations/gen_abc123 \
  -H "Authorization: Bearer trf_live_xxx"

# 200 OK (completed)
{
  "ok": true,
  "data": {
    "taskId": "gen_abc123",
    "type": "image_to_3d",
    "status": "completed",
    "provider": "meshy",
    "outputModelUrl": "https://cdn.trify3d.com/models/gen_abc123/model.glb",
    "thumbnailUrl": "https://cdn.trify3d.com/thumbnails/gen_abc123.png",
    "creditsUsed": 20,
    "errorCode": null,
    "createdAt": "2026-06-22T13:55:00.000Z",
    "completedAt": "2026-06-22T13:58:12.000Z"
  },
  "requestId": "req_3l4m5n6o"
}
Enter fullscreen mode Exit fullscreen mode

Idempotency: Preventing Double Charges

This was the scariest bug class to reason about.

Scenario: A client sends a text-to-3d request. The server receives it, charges 20 credits, and starts the job. But the network drops the response. The client's retry logic fires the same request again. Without protection → 40 credits gone, two identical models generating.

The fix

Any POST endpoint accepts an Idempotency-Key header:

curl -X POST https://trify3d.com/api/v1/generations/text-to-3d \
  -H "Authorization: Bearer trf_live_xxx" \
  -H "Idempotency-Key: client-generated-uuid-v4" \
  -H "Content-Type: application/json" \
  -d '{ "type": "text_to_3d", "prompt": "A medieval sword", "style": "realistic", "mode": "quality" }'
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. First request with a new key → proceeds normally, credits are held against this key.
  2. Same key submitted again → the credit hold is deduplicated. No double charge. The original task's response is returned.

Key rules:

  • 1–255 characters, allowed: A–Z, a–z, 0–9, _, -
  • A random UUID v4 per client action is recommended
  • Invalid format → 400 invalid_idempotency_key
  • If omitted → server generates an internal key (server-side holds still deduplicate, but cross-request client retries won't benefit)

This is the same pattern Stripe uses. It's battle-tested and developers already understand it.


Webhook Delivery

For clients who don't want to poll, Trify3D can POST to their webhook URL when a job finishes.

POST {your webhookUrl}
Headers:
  X-Trify3D-Event: generation.completed
  X-Trify3D-Delivery: gen_abc123
Body:
{
  "taskId": "gen_abc123",
  "type": "image_to_3d",
  "status": "completed",
  "provider": "meshy",
  "outputModelUrl": "https://...",
  "thumbnailUrl": "https://...",
  "creditsUsed": 20,
  "errorCode": null,
  "timestamp": "2026-06-22T14:00:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

Delivery guarantees

Behavior Detail
Retries Up to 3 attempts
Backoff schedule 1s → 5s → 15s
4xx (not 429) Treated as permanent failure, no retry
429 Counts as retryable
Timeout Request aborts after 10s

The design decision here: 4xx = permanent. If the client's endpoint returns 404 or 500, retrying won't help — it's their bug, not a transient issue. Only network errors and 429s deserve retries.

Recommendation to webhook consumers: return any 2xx fast (< 10s), then offload heavy processing to a queue. Don't process the model synchronously in the webhook handler.


Rate Limiting

Each API key gets 600 requests per hour, tracked in a rolling window.

Every response includes headers so clients can self-throttle:

Header Meaning
X-RateLimit-Limit Max requests in the window (600)
X-RateLimit-Remaining Requests remaining in current window
Retry-After Seconds until window resets (only on 429)
X-Request-ID Unique per-request ID for debugging

When the limit is exceeded:

// 429 Too Many Requests
{
  "ok": false,
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Hourly quota exhausted. See Retry-After.",
    "requestId": "req_xyz",
    "details": { "retryAfter": 1842 }
  }
}
Enter fullscreen mode Exit fullscreen mode

A rolling window (not a fixed window) prevents the thundering-herd problem at boundary resets. 600/hour is generous for a generation API — most clients make 1–5 requests, then poll or wait for a webhook.


Error Handling Strategy

Errors fall into three categories with different retry guidance:

Category HTTP Examples Action
Client errors 400–403 Validation, scope, idempotency Don't retry — fix the request
Rate limited 429 Quota exhausted Honor Retry-After, then retry
Server errors 500 Internal error Retry with exponential backoff (1s, 2s, 4s, 8s)

The full error code table:

Code HTTP When
missing_bearer_token 401 No auth header
invalid_api_key 401 Key malformed, revoked, or unknown
insufficient_scope 403 Key lacks required scope
insufficient_credits 402 Account balance too low
validation_failed 400 Body failed validation
invalid_idempotency_key 400 Bad key format
rate_limit_exceeded 429 Hourly quota exhausted
task_not_found 404 No task with that ID for this account
internal_error 500 Unexpected server error

API keys are scoped: read (GET only), write (POST), admin (all). A read-scoped key trying to create a generation gets 403 insufficient_scope with details.required: "write" — so the client knows exactly what to fix.


What I Learned

1. Deterministic routing beats ML routing. I initially wanted to train a model that picks the best engine per input. But API consumers need predictability. A rule chain that any developer can read in 10 seconds builds more trust than a black-box optimizer.

2. Idempotency is not optional for paid APIs. Credits are money. Network retries are inevitable. Without Idempotency-Key, a single dropped response → double charge → support ticket → refund. The Stripe-style pattern eliminates the entire class.

3. Webhook 4xx = permanent was the right call. Early on, I retried all non-2xx responses. That meant retrying into a client's broken endpoint 3 times, wasting resources and confusing their logs. Treating 4xx as permanent (except 429) keeps delivery clean.

4. A rolling rate limit window > fixed window. Fixed windows create burst opportunities at boundaries. Rolling windows smooth traffic and prevent spikes.

5. One credit pool across engines is the real product. The API is just plumbing. The value is that a user with 1,000 credits can spend 100 on Tripo3D, 130 on Meshy, and 150 on Rodin for the same input — then keep the best mesh. That's impossible with three separate accounts.


Try It

If you want to test the API or run the multi-engine comparison workflow:

New accounts start with 50 free credits — enough to run a few generations across each engine and see the differences firsthand.


Building something with the Trify3D API? I'd love to hear about it — drop a comment or reach out.

Top comments (0)