<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Trify3D</title>
    <description>The latest articles on DEV Community by Trify3D (@trify3d).</description>
    <link>https://dev.to/trify3d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4001647%2F8c9bd46d-9083-4e26-83f4-46352a3c0495.png</url>
      <title>DEV Community: Trify3D</title>
      <link>https://dev.to/trify3d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/trify3d"/>
    <language>en</language>
    <item>
      <title>Building a Multi-Engine 3D Generation API: Routing, Credits, and Webhooks</title>
      <dc:creator>Trify3D</dc:creator>
      <pubDate>Wed, 12 Aug 2026 15:55:42 +0000</pubDate>
      <link>https://dev.to/trify3d/building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks-11of</link>
      <guid>https://dev.to/trify3d/building-a-multi-engine-3d-generation-api-routing-credits-and-webhooks-11of</guid>
      <description>&lt;p&gt;How I designed the API layer for &lt;a href="https://trify3d.com" rel="noopener noreferrer"&gt;Trify3D&lt;/a&gt; — 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Every AI 3D engine has a blind spot.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://trify3d.com" rel="noopener noreferrer"&gt;Trify3D&lt;/a&gt; to solve this: &lt;strong&gt;one input, every engine, one credit pool.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;This post is about the API layer that makes that work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Here's the high-level flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)    │
└────────┘ └──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions drove this architecture:&lt;/p&gt;

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

&lt;p&gt;Let me walk through each piece.&lt;/p&gt;




&lt;h2&gt;
  
  
  API Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Base Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Base URL:     https://trify3d.com/api/v1
Auth:         Bearer token (trf_live_…)
Format:       JSON request / response
Rate limit:   600 requests / hour / key (rolling window)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three generation endpoints, one polling endpoint:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/generations/text-to-3d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;Generate from a text prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/generations/image-to-3d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;Generate from a reference image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/generations/multiview-to-3d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;Reconstruct from 2+ photos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/generations/{taskId}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;Poll task status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Response Envelope
&lt;/h3&gt;

&lt;p&gt;Every response — success or error — follows the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Success&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;xx)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;payload&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_abc123"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Error&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="err"&gt;xx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;xx)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"snake_case_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Human-readable message."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;optional&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;context&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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




&lt;h2&gt;
  
  
  Provider Routing
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The routing logic lives in a simple rule chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GenerationRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Provider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Explicit model id always wins&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rodin/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rodin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tripo/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tripo3d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Multipart flag forces Rodin's multi-part pipeline&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;multiPart&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rodin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Speed mode → Tripo3D (fastest engine, ~48s)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tripo3d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Default → Meshy (balanced for quality)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meshy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Why not auto-route with AI?
&lt;/h3&gt;

&lt;p&gt;I considered training a router model that picks the best engine per input. But:&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Engine benchmarks (real data)
&lt;/h3&gt;

&lt;p&gt;These numbers come from our provider config, not marketing materials:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Runtime (median)&lt;/th&gt;
&lt;th&gt;Credit multiplier&lt;/th&gt;
&lt;th&gt;Best at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tripo3D&lt;/td&gt;
&lt;td&gt;~48s&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;Hard-surface props, weapons, vehicles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meshy&lt;/td&gt;
&lt;td&gt;~76s&lt;/td&gt;
&lt;td&gt;1.3x&lt;/td&gt;
&lt;td&gt;Characters, creatures, organic shapes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rodin&lt;/td&gt;
&lt;td&gt;~90s&lt;/td&gt;
&lt;td&gt;1.5x&lt;/td&gt;
&lt;td&gt;High-fidelity PBR for final assets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Async Jobs with Trigger.dev
&lt;/h2&gt;

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

&lt;p&gt;I use &lt;a href="https://trigger.dev" rel="noopener noreferrer"&gt;Trigger.dev&lt;/a&gt; as the async job runner. Here's why:&lt;/p&gt;

&lt;h3&gt;
  
  
  The status lifecycle
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pending → processing → completed | failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a client POSTs a generation request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;API creates a task record (&lt;code&gt;status: pending&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Hands off to Trigger.dev (&lt;code&gt;status: processing&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Trigger.dev calls the provider API, polls until done&lt;/li&gt;
&lt;li&gt;On success → downloads the model, uploads to CDN, sets &lt;code&gt;status: completed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;On failure → sets &lt;code&gt;status: failed&lt;/code&gt; with an &lt;code&gt;errorCode&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The client either polls &lt;code&gt;GET /generations/{taskId}&lt;/code&gt; or receives a webhook. Their choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Trigger.dev?
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Polling example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://trify3d.com/api/v1/generations/gen_abc123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer trf_live_xxx"&lt;/span&gt;

&lt;span class="c"&gt;# 200 OK (completed)&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"ok"&lt;/span&gt;: &lt;span class="nb"&gt;true&lt;/span&gt;,
  &lt;span class="s2"&gt;"data"&lt;/span&gt;: &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"taskId"&lt;/span&gt;: &lt;span class="s2"&gt;"gen_abc123"&lt;/span&gt;,
    &lt;span class="s2"&gt;"type"&lt;/span&gt;: &lt;span class="s2"&gt;"image_to_3d"&lt;/span&gt;,
    &lt;span class="s2"&gt;"status"&lt;/span&gt;: &lt;span class="s2"&gt;"completed"&lt;/span&gt;,
    &lt;span class="s2"&gt;"provider"&lt;/span&gt;: &lt;span class="s2"&gt;"meshy"&lt;/span&gt;,
    &lt;span class="s2"&gt;"outputModelUrl"&lt;/span&gt;: &lt;span class="s2"&gt;"https://cdn.trify3d.com/models/gen_abc123/model.glb"&lt;/span&gt;,
    &lt;span class="s2"&gt;"thumbnailUrl"&lt;/span&gt;: &lt;span class="s2"&gt;"https://cdn.trify3d.com/thumbnails/gen_abc123.png"&lt;/span&gt;,
    &lt;span class="s2"&gt;"creditsUsed"&lt;/span&gt;: 20,
    &lt;span class="s2"&gt;"errorCode"&lt;/span&gt;: null,
    &lt;span class="s2"&gt;"createdAt"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-06-22T13:55:00.000Z"&lt;/span&gt;,
    &lt;span class="s2"&gt;"completedAt"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-06-22T13:58:12.000Z"&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;,
  &lt;span class="s2"&gt;"requestId"&lt;/span&gt;: &lt;span class="s2"&gt;"req_3l4m5n6o"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Idempotency: Preventing Double Charges
&lt;/h2&gt;

&lt;p&gt;This was the scariest bug class to reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; A client sends a &lt;code&gt;text-to-3d&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Any &lt;code&gt;POST&lt;/code&gt; endpoint accepts an &lt;code&gt;Idempotency-Key&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://trify3d.com/api/v1/generations/text-to-3d &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer trf_live_xxx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: client-generated-uuid-v4"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ "type": "text_to_3d", "prompt": "A medieval sword", "style": "realistic", "mode": "quality" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First request with a new key → proceeds normally, credits are held against this key.&lt;/li&gt;
&lt;li&gt;Same key submitted again → the credit hold is deduplicated. No double charge. The original task's response is returned.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Key rules:&lt;/p&gt;

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

&lt;p&gt;This is the same pattern Stripe uses. It's battle-tested and developers already understand it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Webhook Delivery
&lt;/h2&gt;

&lt;p&gt;For clients who don't want to poll, Trify3D can POST to their webhook URL when a job finishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;your&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;webhookUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Headers:&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;X-Trify&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;D-Event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;generation.completed&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;X-Trify&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;D-Delivery:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;gen_abc&lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Body:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"taskId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gen_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image_to_3d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"meshy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputModelUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thumbnailUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"creditsUsed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errorCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-22T14:00:00.000Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Delivery guarantees
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Retries&lt;/td&gt;
&lt;td&gt;Up to 3 attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backoff schedule&lt;/td&gt;
&lt;td&gt;1s → 5s → 15s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4xx (not 429)&lt;/td&gt;
&lt;td&gt;Treated as permanent failure, no retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;Counts as retryable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;td&gt;Request aborts after 10s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The design decision here: &lt;strong&gt;4xx = permanent&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommendation to webhook consumers:&lt;/strong&gt; return any &lt;code&gt;2xx&lt;/code&gt; fast (&amp;lt; 10s), then offload heavy processing to a queue. Don't process the model synchronously in the webhook handler.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rate Limiting
&lt;/h2&gt;

&lt;p&gt;Each API key gets &lt;strong&gt;600 requests per hour&lt;/strong&gt;, tracked in a rolling window.&lt;/p&gt;

&lt;p&gt;Every response includes headers so clients can self-throttle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-RateLimit-Limit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Max requests in the window (600)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-RateLimit-Remaining&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Requests remaining in current window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Retry-After&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Seconds until window resets (only on 429)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Request-ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unique per-request ID for debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When the limit is exceeded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Too&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Many&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Requests&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rate_limit_exceeded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hourly quota exhausted. See Retry-After."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_xyz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"retryAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1842&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error Handling Strategy
&lt;/h2&gt;

&lt;p&gt;Errors fall into three categories with different retry guidance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client errors&lt;/td&gt;
&lt;td&gt;400–403&lt;/td&gt;
&lt;td&gt;Validation, scope, idempotency&lt;/td&gt;
&lt;td&gt;Don't retry — fix the request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate limited&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;Quota exhausted&lt;/td&gt;
&lt;td&gt;Honor &lt;code&gt;Retry-After&lt;/code&gt;, then retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server errors&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;Internal error&lt;/td&gt;
&lt;td&gt;Retry with exponential backoff (1s, 2s, 4s, 8s)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full error code table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;missing_bearer_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;No auth header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invalid_api_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;Key malformed, revoked, or unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;insufficient_scope&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;Key lacks required scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;insufficient_credits&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;402&lt;/td&gt;
&lt;td&gt;Account balance too low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;validation_failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;Body failed validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invalid_idempotency_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;Bad key format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rate_limit_exceeded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;Hourly quota exhausted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;task_not_found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;No task with that ID for this account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;internal_error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;Unexpected server error&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Deterministic routing beats ML routing.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;3. Webhook 4xx = permanent was the right call.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A rolling rate limit window &amp;gt; fixed window.&lt;/strong&gt; Fixed windows create burst opportunities at boundaries. Rolling windows smooth traffic and prevent spikes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. One credit pool across engines is the real product.&lt;/strong&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;If you want to test the API or run the multi-engine comparison workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API docs&lt;/strong&gt;: &lt;a href="https://trify3d.com/developers" rel="noopener noreferrer"&gt;trify3d.com/developers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-model comparison&lt;/strong&gt;: &lt;a href="https://trify3d.com/multi-model-3d-generator" rel="noopener noreferrer"&gt;trify3d.com/multi-model-3d-generator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free tier&lt;/strong&gt;: 50 credits, no credit card required&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;New accounts start with 50 free credits — enough to run a few generations across each engine and see the differences firsthand.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building something with the Trify3D API? I'd love to hear about it — drop a comment or reach out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
