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.
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.
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 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"
{
"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.