Voilae puts a virtual try-on button on any Shopify product page. A shopper picks a garment, and a few seconds later they see it on themselves or on one of sixteen avatars. The part everyone asks about — the model that synthesizes the image — is the part I didn't build and don't own. It calls diffusion models rented by the request. On a good day the model is maybe five percent of the system.

The other ninety-five percent is what makes a rented model safe to sell: a single interface over more than one vendor, a harness that survives timeouts and lost callbacks, guardrails that reject bad input before it ever reaches a paid API, and billing that fires exactly once no matter how many times a webhook is delivered. This is a walk through that ninety-five percent, with the actual code that runs it.

One interface behind every model

Voilae serves three purpose-built try-on models — fashn-v16, leffa, and catvton — across two providers, fal.ai and FASHN.ai. If the choice of model or vendor leaked into the app, every route and every UI component would need to know which one it was talking to. So none of them do. Everything goes through one interface, defined once in the shared @voilae/services package.

packages/services/src/providers/types.ts
export interface TryOnProvider {
  readonly name: ProviderName;        // "fal" | "fashn" | "composite"
  runTryOn(input: TryOnInput): Promise<TryOnResult>;
  submitToQueue(input: TryOnInput, webhookUrl: string): Promise<QueueSubmitResult>;
  parseWebhookPayload(modelId: ModelId, body: unknown): WebhookParseResult;
}
Every model, sync or async, is reachable through the same four methods. Callers never import a vendor SDK directly.

A single router turns a model ID into the right implementation. Only fashn-v16 is switchable at runtime — an environment flag lets me route it to fal, to FASHN, or to a composite that tries both. The other two models always run on fal. Swapping a vendor is a one-line config change, not a code change.

packages/services/src/providers/index.ts
export function getProvider(modelId: ModelId): TryOnProvider {
  if (modelId === "fashn-v16") {
    const flag = process.env.TRYON_PROVIDER_FASHN_V16 ?? "fal";
    if (flag === "fashn")     return fashnProvider;
    if (flag === "composite") return compositeProvider;
    return falProvider;
  }
  return falProvider; // leffa, catvton
}
The provider router. Model choice is a runtime flag; the rest of the app is oblivious to it.

The one place model identity does matter — the physical endpoint — is isolated to a config table. Garment-type differences between models are absorbed inside each provider, which maps Voilae's internal garment vocabulary onto whatever shape that vendor's schema expects.

A vendor should be a config value, not an assumption baked into a hundred call sites.

The harness around the call

A model call is a network call to someone else's GPU, and it fails the way network calls fail: it times out, it 500s under load, it succeeds but the callback telling you so never arrives. The harness exists so none of those failure modes reach a shopper as a spinner that never resolves.

The first layer is ordinary and boring: bounded retries with exponential backoff and jitter, so a transient blip doesn't become a user-visible error and a thundering herd of retries doesn't hammer a recovering API in lockstep.

packages/services/src/providers/retry.ts
// 3 attempts · 1s base · 8s cap · 200ms jitter · pluggable shouldRetry
for (let i = 0; i < attempts; i++) {
  try { return await fn(); }
  catch (err) {
    if (i === attempts - 1 || !shouldRetry(err)) throw err;
    const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** i) + Math.random() * jitterMs;
    await sleep(delay);
  }
}
Backoff with jitter. The FASHN SDK adds its own retry budget and a 30-second per-request timeout on top.

The harder problem is the long tail. Some renders finish in under two seconds; some take thirty; a few get stuck in a vendor's queue for minutes. You cannot hold an HTTP request open for that, so slow jobs go async: Voilae submits the job, stores the provider's request ID against the session, and hands back control. The provider calls a signed webhook when the image is ready.

Webhooks are the weakest link in any async pipeline — they get lost, delayed, or delivered twice. So the webhook is never trusted to be the only path to completion. A scheduled job reconciles anything the webhook missed.

async try-onself-healing
submitToQueue() · store falRequestIdprovider renderssigned webhook → completeTryOn()cron polls queue.status() for anything stuck > 10 min
The happy path is the webhook. The safety net is a cron that reconciles lost callbacks and times jobs out at fifteen minutes.
apps/shopify/app/lib/cleanup.server.ts
const status = await fal.queue.status(endpoint, { requestId: session.falRequestId! });

if (status.status === "COMPLETED") {
  const result = await fal.queue.result(endpoint, { requestId: session.falRequestId! });
  await completeTryOn(session, result);
} else if (status.status === "IN_QUEUE" || status.status === "IN_PROGRESS") {
  if (session.createdAt < fifteenMinAgo)
    await failTryOn(session.id, "Processing timed out after 15 minutes");
}
The reconciler, run on a schedule. A stuck session either completes late or is failed cleanly — it never hangs forever.

Failover without a single vendor

Uptime that depends on one API is only as good as that API's worst day. The composite provider makes the primary vendor's outage invisible: it tries FASHN first and, on any failure, falls through to fal — same interface, same result shape, both failures logged.

packages/services/src/providers/composite.ts
async runTryOn(input) {
  try {
    return await fashnProvider.runTryOn(input);   // primary
  } catch (err) {
    console.warn("[composite] fashn failed, falling back to fal", err);
    return await falProvider.runTryOn(input);     // fallback
  }
}
Synchronous failover. One vendor's bad day becomes a log line, not an outage.

Async failover is subtler, and it's worth being honest about why. A queued job's webhook has to come back to the endpoint for the vendor that actually ran it — a fal job cannot be completed by the FASHN webhook handler. So for queued work the fallback lives at the call site, which picks the matching webhook URL for whichever provider it submitted to. The composite provider deliberately refuses to submit to a queue at all, precisely so this decision can't be made in the wrong place.

The right abstraction sometimes means refusing to do the convenient thing, so the mistake becomes impossible instead of merely unlikely.

Guardrails at the front door

The cheapest request to serve is the one you reject before it costs anything. Every try-on passes through a fixed sequence of checks, and the expensive model call sits at the very end — reached only by requests that have already proven they're well-formed, authorized, in-budget, and not a duplicate.

api.tryonordered
1 · authverify widget token — 401
2 · inputtype · size ≤ 10MB · SSRF allowlist — 422
3 · tenantisActive · avatar scoping — 403
4 · quotaplan limit · overage or block — 429
5 · trafficrate-limit + in-flight lock — 409
6 · modelrunTryOn() — reached last
The gauntlet a request runs before it's allowed to cost money. Each stage has its own failure code.

Two of these deserve a closer look. The first is the anti-SSRF allowlist. A try-on takes image URLs, and image URLs are an open door: point them at an internal address and a naive server will happily fetch it. So garment URLs must be HTTPS on a Shopify CDN host, result URLs are restricted to the providers' and my own storage hosts, and everything else — http, data:, file:, raw IPs, 169.254.169.254, localhost — is refused outright.

The second is traffic control. A sliding-window rate limit in Redis caps requests per shop and per IP, and a short-lived in-flight lock — a Redis SET NX keyed on the request's content hash — means a shopper who double-clicks generate gets one render, not two. The passcode gate protecting internal and demo apps uses an HMAC-signed cookie verified in constant time; webhooks are authenticated by an HMAC signature carried in the callback URL and checked the same way. Everything that can be forged is signed, and everything signed is compared without leaking timing.

What each guardrail is actually protecting

  • Input validation and the SSRF allowlist protect the infrastructure — no malformed payloads, no server-side requests to places I don't trust.
  • Quota and rate limits protect the economics — no single tenant can run up an unbounded bill on a rented GPU.
  • The in-flight lock protects the shopper — one intent, one render, one charge.

Bill exactly once

Usage-based billing and at-least-once webhooks are a dangerous pair. If the webhook arrives twice — or the webhook and the reconciliation cron both complete the same job — the naive outcome is a double charge. The fix is to make "has this session been billed" an atomic decision that only one caller can win.

apps/shopify/app/lib/billing.server.ts
const result = await prisma.tryOnSession.updateMany({
  where: { id: sessionId, billed: false },
  data:  { billed: true },
});
return result.count === 1; // only the first caller wins the slot
claimBillingSlot(): a conditional update is the atomic gate. Both the webhook and the cron guard their usage increment behind it.

The condition billed: false lives in the WHERE clause, so the database — not application logic — decides the race. The first caller flips the flag and gets count === 1; every later caller matches zero rows and quietly does nothing. The charge to Shopify carries the session ID as its own idempotency key, so even a retried upstream call can't post the overage twice. Duplicate delivery stops being a bug and becomes a no-op.

Safety, caching, and knowing what happened

Content safety is a place where it pays to be precise rather than reassuring. Voilae runs no moderation model of its own — safety is delegated to the providers, and only one of them, leffa, returns an explicit NSFW signal. Where that signal exists, I honor it: a flagged result is logged as such and returned as a clean 422, never surfaced to the shopper. I say exactly that, rather than claiming a safety net I don't operate.

apps/internal/src/app/api/tryon/route.ts
if (modelId === "leffa" && result.hasNsfwConcepts) {
  logTryOn({ status: "nsfw_flagged", ... });
  return NextResponse.json(
    { error: "The result was flagged for inappropriate content" },
    { status: 422 },
  );
}
The one provider that reports NSFW concerns is trusted to; the flag becomes a logged, contained rejection.

Because the models cost real money per render, results are cached on the tuple that fully determines them — provider, person, and garment. A repeat try-on of the same combination is served from Supabase Storage in milliseconds for nothing: the first render is a cold miss that costs a few cents, every one after is a free cache hit.

And every terminal outcome is recorded. A fire-and-forget write lands each try-on in a tryon_logs table — success, error, or NSFW flag — with the model, provider, garment type, elapsed time, and any error message. Failures set the session to FAILED with a reason; side effects like email and cache-warming all swallow their own errors so they can never strand a session. On top of that I pull fal's own per-endpoint analytics — request counts, error rates, p50 through p90 latencies — so a degrading vendor shows up as a trend line before it shows up as a support ticket.

You can't run a rented model responsibly if you can't see, after the fact, exactly what it did and how long it took.

What wrapping the call buys

None of this is glamorous, and that's the point. The model is a commodity I rent; the durable engineering is the interface that makes vendors interchangeable, the harness that turns flaky async calls into reliable ones, the guardrails that reject cost before it's incurred, and the atomic billing that survives duplicate delivery. That's what lets Voilae put a model it didn't train behind a button on someone else's store and charge for it with a straight face.

It's also the pattern I bring to anything model-powered: pick the right model for the job, then spend the real effort on everything around the call. If you've got a model that works in a demo and needs to survive production, that's the conversation I like to have.

Have a model that works in a demo and needs to survive production?

Get in touch