Building a Multi-Engine 3D Generation API: Routing, Credits, and Webhooks Trify3D has launched an API that routes a single 3D generation request across multiple AI engines—Tripo3D, Meshy, and Rodin—allowing users to compare results side by side with one credit pool. The API layer handles provider routing, async job management via Trigger.dev, idempotency for credit safety, and webhook delivery. The platform aims to overcome each engine's individual weaknesses by offering a unified interface. How I designed the API layer for Trify3D https://trify3d.com — 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. 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 https://trify3d.com 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. 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 │ └────────┘ └──────────┘ Three decisions drove this architecture: Let me walk through each piece. Base URL: https://trify3d.com/api/v1 Auth: Bearer token trf live … Format: JSON request / response Rate limit: 600 requests / hour / key rolling window 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 | 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 / } } } 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. 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"; } That's it. No ML-based routing, no A/B test framework. A deterministic rule chain that any developer can read and predict. I considered training a router model that picks the best engine per input. But: 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. 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 https://trigger.dev as the async job runner. Here's why: pending → processing → completed | failed When a client POSTs a generation request: status: pending status: processing status: completed status: failed with an errorCode The client either polls GET /generations/{taskId} or receives a webhook. Their choice. 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" } 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. 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" }' How it works: Key rules: A–Z , a–z , 0–9 , , - 400 invalid idempotency key This is the same pattern Stripe uses. It's battle-tested and developers already understand it. 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" } | 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. 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 } } } 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. 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. 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. 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.