{"slug": "building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks", "title": "Building a Multi-Engine 3D Generation API: Routing, Credits, and Webhooks", "summary": "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.", "body_md": "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.\n\nEvery AI 3D engine has a blind spot.\n\n**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.\n\nIf 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.\n\nI 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.\n\nThis post is about the API layer that makes that work.\n\nHere's the high-level flow:\n\n```\nClient Request\n    │\n    ▼\n┌──────────────────┐\n│  API Gateway     │  Bearer auth, rate limit, idempotency check\n│  (trify3d.com)   │\n└────────┬─────────┘\n         │\n         ▼\n┌──────────────────┐\n│ Provider Router  │  Routes to Tripo3D / Meshy / Rodin\n│  (mode + model)  │  based on mode + model prefix\n└────────┬─────────┘\n         │\n    ┌────┼────┐\n    ▼    ▼    ▼\n┌──────┐┌──────┐┌──────┐\n│Tripo3D││Meshy ││Rodin │  Async generation\n└──┬───┘└──┬───┘└──┬───┘\n   │       │       │\n   └───────┼───────┘\n           ▼\n┌──────────────────┐\n│  Trigger.dev     │  Job orchestration, retries, 10-min timeout\n│  (async runner)  │\n└────────┬─────────┘\n         │\n    ┌────┴────┐\n    ▼         ▼\n┌────────┐ ┌──────────┐\n│  Poll  │ │  Webhook  │  Client picks one or both\n│ (GET)  │ │ (POST)    │\n└────────┘ └──────────┘\n```\n\nThree decisions drove this architecture:\n\nLet me walk through each piece.\n\n```\nBase URL:     https://trify3d.com/api/v1\nAuth:         Bearer token (trf_live_…)\nFormat:       JSON request / response\nRate limit:   600 requests / hour / key (rolling window)\n```\n\nThree generation endpoints, one polling endpoint:\n\n| Endpoint | Method | Purpose |\n|---|---|---|\n`/generations/text-to-3d` |\nPOST | Generate from a text prompt |\n`/generations/image-to-3d` |\nPOST | Generate from a reference image |\n`/generations/multiview-to-3d` |\nPOST | Reconstruct from 2+ photos |\n`/generations/{taskId}` |\nGET | Poll task status |\n\nEvery response — success or error — follows the same shape:\n\n```\n// Success (2xx)\n{\n  \"ok\": true,\n  \"data\": { /* payload */ },\n  \"requestId\": \"req_abc123\"\n}\n\n// Error (4xx / 5xx)\n{\n  \"ok\": false,\n  \"error\": {\n    \"code\": \"snake_case_code\",\n    \"message\": \"Human-readable message.\",\n    \"requestId\": \"req_abc123\",\n    \"details\": { /* optional context */ }\n  }\n}\n```\n\nThe `requestId`\n\nappears in an `X-Request-ID`\n\nheader too. When a user reports an issue, one ID traces the entire request lifecycle. This has saved me hours of debugging.\n\nThis 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.\n\nThe routing logic lives in a simple rule chain:\n\n```\nfunction resolveProvider(body: GenerationRequest): Provider {\n  // Explicit model id always wins\n  if (body.model?.startsWith(\"rodin/\")) return \"rodin\";\n  if (body.model?.startsWith(\"tripo/\")) return \"tripo3d\";\n\n  // Multipart flag forces Rodin's multi-part pipeline\n  if (body.multiPart === true) return \"rodin\";\n\n  // Speed mode → Tripo3D (fastest engine, ~48s)\n  if (body.mode === \"speed\") return \"tripo3d\";\n\n  // Default → Meshy (balanced for quality)\n  return \"meshy\";\n}\n```\n\nThat's it. No ML-based routing, no A/B test framework. A deterministic rule chain that any developer can read and predict.\n\nI considered training a router model that picks the best engine per input. But:\n\nThese numbers come from our provider config, not marketing materials:\n\n| Engine | Runtime (median) | Credit multiplier | Best at |\n|---|---|---|---|\n| Tripo3D | ~48s | 1.0x | Hard-surface props, weapons, vehicles |\n| Meshy | ~76s | 1.3x | Characters, creatures, organic shapes |\n| Rodin | ~90s | 1.5x | High-fidelity PBR for final assets |\n\nA 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.\n\n3D generation is slow. Holding an HTTP connection open for 90 seconds is a recipe for timeouts, load balancer issues, and frustrated users.\n\nI use [Trigger.dev](https://trigger.dev) as the async job runner. Here's why:\n\n```\npending → processing → completed | failed\n```\n\nWhen a client POSTs a generation request:\n\n`status: pending`\n\n)`status: processing`\n\n)`status: completed`\n\n`status: failed`\n\nwith an `errorCode`\n\nThe client either polls `GET /generations/{taskId}`\n\nor receives a webhook. Their choice.\n\n```\ncurl https://trify3d.com/api/v1/generations/gen_abc123 \\\n  -H \"Authorization: Bearer trf_live_xxx\"\n\n# 200 OK (completed)\n{\n  \"ok\": true,\n  \"data\": {\n    \"taskId\": \"gen_abc123\",\n    \"type\": \"image_to_3d\",\n    \"status\": \"completed\",\n    \"provider\": \"meshy\",\n    \"outputModelUrl\": \"https://cdn.trify3d.com/models/gen_abc123/model.glb\",\n    \"thumbnailUrl\": \"https://cdn.trify3d.com/thumbnails/gen_abc123.png\",\n    \"creditsUsed\": 20,\n    \"errorCode\": null,\n    \"createdAt\": \"2026-06-22T13:55:00.000Z\",\n    \"completedAt\": \"2026-06-22T13:58:12.000Z\"\n  },\n  \"requestId\": \"req_3l4m5n6o\"\n}\n```\n\nThis was the scariest bug class to reason about.\n\n**Scenario:** A client sends a `text-to-3d`\n\nrequest. 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.\n\nAny `POST`\n\nendpoint accepts an `Idempotency-Key`\n\nheader:\n\n```\ncurl -X POST https://trify3d.com/api/v1/generations/text-to-3d \\\n  -H \"Authorization: Bearer trf_live_xxx\" \\\n  -H \"Idempotency-Key: client-generated-uuid-v4\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"type\": \"text_to_3d\", \"prompt\": \"A medieval sword\", \"style\": \"realistic\", \"mode\": \"quality\" }'\n```\n\n**How it works:**\n\nKey rules:\n\n`A–Z`\n\n, `a–z`\n\n, `0–9`\n\n, `_`\n\n, `-`\n\n`400 invalid_idempotency_key`\n\nThis is the same pattern Stripe uses. It's battle-tested and developers already understand it.\n\nFor clients who don't want to poll, Trify3D can POST to their webhook URL when a job finishes.\n\n```\nPOST {your webhookUrl}\nHeaders:\n  X-Trify3D-Event: generation.completed\n  X-Trify3D-Delivery: gen_abc123\nBody:\n{\n  \"taskId\": \"gen_abc123\",\n  \"type\": \"image_to_3d\",\n  \"status\": \"completed\",\n  \"provider\": \"meshy\",\n  \"outputModelUrl\": \"https://...\",\n  \"thumbnailUrl\": \"https://...\",\n  \"creditsUsed\": 20,\n  \"errorCode\": null,\n  \"timestamp\": \"2026-06-22T14:00:00.000Z\"\n}\n```\n\n| Behavior | Detail |\n|---|---|\n| Retries | Up to 3 attempts |\n| Backoff schedule | 1s → 5s → 15s |\n| 4xx (not 429) | Treated as permanent failure, no retry |\n| 429 | Counts as retryable |\n| Timeout | Request aborts after 10s |\n\nThe 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.\n\n**Recommendation to webhook consumers:** return any `2xx`\n\nfast (< 10s), then offload heavy processing to a queue. Don't process the model synchronously in the webhook handler.\n\nEach API key gets **600 requests per hour**, tracked in a rolling window.\n\nEvery response includes headers so clients can self-throttle:\n\n| Header | Meaning |\n|---|---|\n`X-RateLimit-Limit` |\nMax requests in the window (600) |\n`X-RateLimit-Remaining` |\nRequests remaining in current window |\n`Retry-After` |\nSeconds until window resets (only on 429) |\n`X-Request-ID` |\nUnique per-request ID for debugging |\n\nWhen the limit is exceeded:\n\n```\n// 429 Too Many Requests\n{\n  \"ok\": false,\n  \"error\": {\n    \"code\": \"rate_limit_exceeded\",\n    \"message\": \"Hourly quota exhausted. See Retry-After.\",\n    \"requestId\": \"req_xyz\",\n    \"details\": { \"retryAfter\": 1842 }\n  }\n}\n```\n\nA 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.\n\nErrors fall into three categories with different retry guidance:\n\n| Category | HTTP | Examples | Action |\n|---|---|---|---|\n| Client errors | 400–403 | Validation, scope, idempotency | Don't retry — fix the request |\n| Rate limited | 429 | Quota exhausted | Honor `Retry-After` , then retry |\n| Server errors | 500 | Internal error | Retry with exponential backoff (1s, 2s, 4s, 8s) |\n\nThe full error code table:\n\n| Code | HTTP | When |\n|---|---|---|\n`missing_bearer_token` |\n401 | No auth header |\n`invalid_api_key` |\n401 | Key malformed, revoked, or unknown |\n`insufficient_scope` |\n403 | Key lacks required scope |\n`insufficient_credits` |\n402 | Account balance too low |\n`validation_failed` |\n400 | Body failed validation |\n`invalid_idempotency_key` |\n400 | Bad key format |\n`rate_limit_exceeded` |\n429 | Hourly quota exhausted |\n`task_not_found` |\n404 | No task with that ID for this account |\n`internal_error` |\n500 | Unexpected server error |\n\nAPI keys are scoped: `read`\n\n(GET only), `write`\n\n(POST), `admin`\n\n(all). A `read`\n\n-scoped key trying to create a generation gets `403 insufficient_scope`\n\nwith `details.required: \"write\"`\n\n— so the client knows exactly what to fix.\n\n**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.\n\n**2. Idempotency is not optional for paid APIs.** Credits are money. Network retries are inevitable. Without `Idempotency-Key`\n\n, a single dropped response → double charge → support ticket → refund. The Stripe-style pattern eliminates the entire class.\n\n**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.\n\n**4. A rolling rate limit window > fixed window.** Fixed windows create burst opportunities at boundaries. Rolling windows smooth traffic and prevent spikes.\n\n**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.\n\nIf you want to test the API or run the multi-engine comparison workflow:\n\nNew accounts start with 50 free credits — enough to run a few generations across each engine and see the differences firsthand.\n\n*Building something with the Trify3D API? I'd love to hear about it — drop a comment or reach out.*", "url": "https://wpnews.pro/news/building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks", "canonical_source": "https://dev.to/trify3d/building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks-11of", "published_at": "2026-08-12 15:55:42+00:00", "updated_at": "2026-08-12 16:20:59.135622+00:00", "lang": "en", "topics": ["ai-products", "ai-infrastructure", "developer-tools", "generative-ai"], "entities": ["Trify3D", "Tripo3D", "Meshy", "Rodin", "Trigger.dev"], "alternates": {"html": "https://wpnews.pro/news/building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks", "markdown": "https://wpnews.pro/news/building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks.md", "text": "https://wpnews.pro/news/building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks.txt", "jsonld": "https://wpnews.pro/news/building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks.jsonld"}}