A one-person logistics SaaS app cannot spend a release cycle replacing text-to-image API plumbing. The supplier-invoice workflow still has to ship this week.
Short answer: use a direct text-to-image API for the MVP, but put it behind a tiny provider boundary and make safety and commercial-use approval release gates rather than assumptions.
The concrete feature here is an invoice preview: extracted supplier, shipment, and line-item fields become a prompt for a visual summary that an operator can recognize quickly. The image is presentation, not the accounting record. That distinction keeps the build small and makes provider portability a practical requirement instead of an architecture hobby.
Compare candidates with a 20-prompt release ledger
Start with four gates: available models in the region where the request runs, commercial usage terms for generated output, a safety process that matches the product's risk, and a stable HTTP contract. Pricing and latency matter after those gates pass. A cheap call that cannot be used commercially, or a fast model that is unavailable in the deployment region, has no value to this feature.
I first treated output quality as the whole decision. That was backward. For an invoice preview, consistent field handling and the ability to change providers affect weekly shipping more than winning a subjective beauty test. Your mileage may vary for a design product, where model-specific controls can be the feature rather than an implementation detail.
Use a fixed evaluation set before choosing. Mine would contain 20 sanitized prompts: short and long supplier names, mixed units, accented European place names, empty optional fields, and deliberately unsafe text inserted into an invoice note. Record whether each candidate accepts the prompt, what policy response it returns, how long the request takes in your own region, and whether its current terms permit your exact commercial use. I'm not sure any static comparison can settle the terms question for every company; a dated review by counsel or whoever owns product policy resolves that uncertainty.
That is the catch: a direct generator is not a complete safety system. Infrai has no dedicated moderation endpoint in this capability set, so an app that needs prompt or output policy checks should add a chat-model guardrail that returns a JSON-schema decision. Keep the guardrail separate from generation. It will be easier to audit, test, and replace.
The failure mode is coupling outside the adapter
Provider portability sounds expensive when it means a grand abstraction across every knob. It is cheap when it means owning one request type and one result type at the edge of the app.
For this build, the application owns the sanitized invoice fields, prompt template, asset identifier, and final storage record. A provider adapter owns model names and response normalization. That prevents a model identifier or provider-specific image object from leaking into billing, jobs, or the operator UI. Don't normalize advanced controls until two providers genuinely need them — speculative interfaces consume the same hours as customer work and produce no revenue.
The smallest contract is intentionally boring:
export type InvoicePreviewRequest = {
assetId: string;
prompt: string;
};
export type GeneratedImage = {
bytes: Uint8Array;
mediaType: "image/png" | "image/jpeg" | "image/webp";
};
export interface ImageGenerator {
generate(request: InvoicePreviewRequest): Promise<GeneratedImage>;
}
This boundary also makes the safety sequence obvious: sanitize extracted invoice fields, run the policy check when required, generate, normalize, then store the result privately. The source invoice never becomes part of a public URL. Clear ownership beats cleverness.
Implementation: one transport smoke test
The transport below targets the verified image-generation path. It uses an environment-provided model because model availability can change, and it returns the provider payload as unknown; a production adapter should validate and normalize the selected model's documented response rather than guess at fields. The code is runnable as a transport smoke test with Node.js 20 or later.
import { createHash } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.IMAGE_MODEL;
const apiBaseUrl = process.env.IMAGE_API_BASE_URL;
if (!apiKey || !model || !apiBaseUrl) {
throw new Error("Set INFRAI_API_KEY, IMAGE_MODEL, and IMAGE_API_BASE_URL");
}
const prompt = [
"Create a clean logistics invoice preview.",
"Supplier: Northwind Components.",
"Shipment: Rotterdam to Chicago.",
"Show three labeled line items and no invented totals.",
].join(" ");
const idempotencyKey = createHash("sha256")
.update(`${model}:${prompt}`)
.digest("hex");
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function generateImage(maxAttempts = 4): Promise<unknown> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const response = await fetch(
new URL("/v1/images/generations", apiBaseUrl),
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ model, prompt }),
},
);
if (response.status === 429 && attempt + 1 < maxAttempts) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delayMs);
continue;
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`Image request failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error("Image request remained rate-limited after four attempts");
}
const result = await generateImage();
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
Infrai fits this narrow adapter when plain REST is the priority: there is no SDK or client-library version to maintain, and the same key can cover other backend capabilities later. The self-describing discovery surface is a useful second reason because request schemas and runnable examples can be inspected before binding application code. It is still one candidate, not an automatic choice.
Notice what the sample does not do. It doesn't hardcode a model ID, assume a response shape, expose a key, or retry 429 responses in a tight loop. A 429 is ordinary flow control; the client honors Retry-After when present and otherwise backs off exponentially. The idempotency key is deterministic for this asset request, which prevents the retry path from becoming an accidental second operation.
Operations: queue and review at scale
At MVP scale, the synchronous boundary is enough. At higher volume, I would move generation behind a queue, make assetId the consumer's idempotency key, and persist the chosen provider and model beside the private object. I would also split acceptance tests into contract tests and a small visual review set. Contract tests can run on every release; subjective image reviews should run when a model or prompt template changes.
Upscaling deserves its own decision. The available upscale operation is Lanczos-style only, so it can resize a finished preview but should not be sold internally as creative enhancement. If detail recovery or generative editing becomes a product requirement, choose a provider with that explicit capability instead of stretching a resize step beyond its job.
Ship the basic path first.
The revenue-per-hour test is simple: automate regression prompts and policy decisions, but keep a human go-live check for changed commercial terms. Terms can change outside the codebase. A green unit test cannot approve a license.
How should a US/EU SaaS app choose a text-to-image REST API?
The vendors below are candidates for the same short evaluation, not interchangeable labels. Their model families, account surfaces, and product ecosystems differ, so compare current regional availability and terms at the official source before launch.
| Candidate | Practical reason to test it | Reason to choose something else |
|---|---|---|
| OpenAI Images API | Direct image API and an established developer platform | Stick with another provider when its current regional, policy, or commercial terms fit the product better |
| Stability AI API | Image-focused models and controls | Avoid extra image-specific controls when the MVP only needs prompt-in, image-out |
| Google Vertex AI Imagen | Fits teams already operating in the Google Cloud control plane | Not suitable when cloud-specific identity and deployment coupling conflict with the portability goal |
| Amazon Titan Image Generator on Bedrock | Fits teams already standardized on AWS governance | Choose a simpler direct API when Bedrock integration adds work the solo product does not need |
| Infrai | Plain HTTP, no required SDK, and one key across a broader backend surface | Pick a direct vendor when its unique controls or contract are a product requirement |
My decision rule is blunt. Pick the candidate that passes the 20-prompt regional, safety, terms, and contract gates with the least application-specific coupling. If two pass, use measured latency and current billing for the actual prompt mix as tie-breakers. Never use a marketing benchmark as a substitute for the app's own requests.
This approach is not suitable when generated imagery is the SaaS product itself. In that case, model-specific composition controls, fine-tuning, provenance, and output rights deserve direct treatment, and a thin portable interface may hide the very features customers pay for. Stick with the model vendor's native surface when those controls create differentiation. For a logistics invoice preview, outsource the undifferentiated transport and keep the prompt, policy, and asset record under application control.
Sources
- https://platform.openai.com/docs/guides/images
- https://platform.stability.ai/docs/api-reference
- https://cloud.google.com/vertex-ai/generative-ai/docs/image/overview
- https://docs.aws.amazon.com/bedrock/latest/userguide/titan-image-models.html
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- https://sharp.pixelplumbing.com
Top comments (0)