Choose a signed object URL as the default response from a text-to-image endpoint, and permit Base64 only for small, explicitly bounded clients. The deciding constraint is per-tenant cost visibility: an image hidden inside a JSON response is easy to ship, but an object with a tenant-scoped key, immutable metadata, and a separate delivery event is much easier to meter, expire, retry, and investigate.
Short answer: for an e-commerce hiring system that turns a candidate's rubric scores into a review image, validate a structured prompt, create one generation record before calling the model, store the resulting bytes once, and return a short-lived signed URL. This is an architecture choice, not a claim that URLs make generation cheaper. They make ownership and byte movement observable.
Should a text-to-image API endpoint return a signed URL or Base64?
The endpoint accepts text-derived, structured facts about a candidate and produces an image for a hiring review. It must not decide who gets hired. Scores, rubric versions, and reviewer decisions belong in the system of record; the generated scorecard is a presentation artifact that can be regenerated or deleted under the applicable retention policy.
The architectural decision is narrow: return a signed URL by default, with Base64 as an opt-in response mode under a documented byte limit. The public contract can expose one POST endpoint while model adapters and object storage remain internal details.
Four invariants matter more than the provider call:
- A verified tenant identity, not a tenant string supplied in the request body, selects the quota, storage prefix, and ledger partition.
- The accepted prompt is structured and bounded. Free-form instructions cannot override the rubric template or smuggle arbitrary candidate data into the image.
- One idempotency key identifies one logical generation within a tenant. A retry may retrieve the prior result; it must not silently create another billable job.
- The ledger records the requested operation and its terminal outcome even when generation, storage, or response delivery fails.
No ambiguity there.
The failure boundaries should be equally explicit. Authentication and validation happen before a generation job exists. Once accepted, the request receives a stable operation ID. Model output is untrusted binary input: verify the declared media type, decode it with a size bound, and reject dimensions or formats outside policy. Object persistence must complete before the operation becomes succeeded; URL signing comes afterward because a signature is temporary access, not evidence of durable storage. A client disconnect after success changes neither the stored object nor the ledger state.
Compare the response envelopes before choosing one
Both forms can represent the same image. Their behavior around that image is different.
| Decision factor | Signed object URL | Base64 in JSON |
|---|---|---|
| Tenant attribution | Storage key and download events can carry the authenticated tenant and operation IDs | Generation is attributable, but later copies and reads disappear into application traffic |
| Retry behavior | Return a newly signed URL for the same immutable object | Re-encode and retransmit the full image unless the entire response is cached |
| Memory pressure | Application can stream bytes to storage and return a small response | Application and client hold an expanded textual representation inside JSON |
| Access boundary | Short expiry, narrow object scope, and authorization before signing | Possession of the response is possession of all image bytes |
| Offline or single-message transport | Requires a later fetch before expiry | Self-contained and useful for tightly bounded messages or test fixtures |
| Lifecycle | Object retention and URL expiry are separate controls | Retention follows every log, queue, cache, and database that captures the JSON |
Base64 encodes each three input bytes as four characters, apart from padding, so it increases the representation size before JSON, buffering, and logging overhead enter the picture. That 4-to-3 relationship is defined by RFC 4648; it is not a benchmark. For a backend that already has to account for generated and delivered bytes, deliberately adding a second large representation deserves a concrete reason.
Signed URLs have their own sharp edge. A signature is a bearer capability until it expires. Keep its lifetime short enough for the consumer workflow, avoid putting it in analytics events, and never treat an unguessable object name as authorization. A download service or storage audit stream should connect each issued access grant to the tenant, operation, object version, and expiry without recording the signature itself. The limitation is concrete: this option is not suitable when the consumer cannot make a second network request or cannot fetch the object before the grant expires; bounded Base64 is the better choice there.
Implement the ledger boundary, then attack it
The important code is the state transition around generation, not an SDK-specific method name. The following Python sketch leaves authentication, durable implementations, and the model adapter behind interfaces on purpose. It shows where the boundaries belong without pretending that an in-memory counter is a billing ledger.
import base64
import hashlib
from dataclasses import dataclass
from typing import Literal, Protocol
MAX_PROMPT_CHARS = 2_000
MAX_IMAGE_BYTES = 8 * 1024 * 1024
ALLOWED_FORMATS = {"image/png", "image/jpeg", "image/webp"}
@dataclass(frozen=True)
class ImageRequest:
role_id: str
rubric_version: str
candidate_label: str
scores: dict[str, int]
response_mode: Literal["url", "base64"] = "url"
class Generator(Protocol):
async def generate(self, prompt: str) -> tuple[bytes, str]: ...
class Store(Protocol):
async def put_immutable(
self, key: str, data: bytes, media_type: str, metadata: dict[str, str]
) -> str: ...
async def sign_read(self, object_version: str, expires_in_seconds: int) -> str: ...
async def create_rubric_image(
*, tenant_id: str, idempotency_key: str, request: ImageRequest,
generator: Generator, store: Store, ledger
) -> dict:
validate_request(request)
operation = await ledger.begin_once(
tenant_id=tenant_id,
idempotency_key=idempotency_key,
rubric_version=request.rubric_version,
response_mode=request.response_mode,
)
if operation.is_complete:
return await render_existing(operation, request.response_mode, store)
prompt = render_controlled_prompt(request)
if len(prompt) > MAX_PROMPT_CHARS:
await ledger.reject(operation.id, reason="rendered_prompt_too_long")
raise ValueError("rendered prompt exceeds policy")
try:
image_bytes, media_type = await generator.generate(prompt)
validate_image(image_bytes, media_type, MAX_IMAGE_BYTES, ALLOWED_FORMATS)
digest = hashlib.sha256(image_bytes).hexdigest()
object_key = f"tenants/{tenant_id}/rubric-images/{operation.id}/{digest}"
version = await store.put_immutable(
object_key,
image_bytes,
media_type,
{"tenant_id": tenant_id, "operation_id": operation.id},
)
await ledger.succeed(
operation.id,
object_version=version,
image_bytes=len(image_bytes),
media_type=media_type,
sha256=digest,
)
except Exception as error:
await ledger.fail(operation.id, failure_class=classify_failure(error))
raise
if request.response_mode == "base64":
return {
"operation_id": operation.id,
"media_type": media_type,
"image_base64": base64.b64encode(image_bytes).decode("ascii"),
}
return {
"operation_id": operation.id,
"media_type": media_type,
"url": await store.sign_read(version, expires_in_seconds=300),
"expires_in_seconds": 300,
}
The helpers are policy, not decoration. validate_request should reject unknown fields, empty identifiers, score keys outside the selected rubric, non-integer values, and values outside the rubric's declared range. render_controlled_prompt should place validated values into a server-owned template; accepting a ready-made prompt would collapse the distinction between candidate data and generation instructions. validate_image must examine decoded bytes rather than trusting a media-type string returned by an upstream service.
There is a subtle transaction problem in the sketch: no ordinary database transaction can atomically cover a remote generator and object storage. The operation record therefore needs monotonic states and reconciliation. A worker may persist an object and crash before marking success. A reconciler can locate objects by operation ID, verify the digest and version, and complete the record without generating again. The inverse case, a failed upload after successful generation, remains failed or retryable according to a documented policy; it must not be labeled successful merely because the expensive step ran.
Record quantities, not guessed money. Useful dimensions include tenant ID, operation ID, rubric version, requested image parameters, provider adapter, attempt count, generated byte count, stored byte count, delivered byte count, and terminal failure class. Convert those quantities into internal cost reports with a versioned rate table outside the request path. This keeps historical reports reproducible when contracts or infrastructure change.
Candidate names and rubric commentary do not belong in metric labels, object keys, exception messages, or tracing baggage. High-cardinality identifiers can live in structured audit records with controlled access; aggregate metrics should use bounded labels such as response mode, result class, and adapter. OpenTelemetry's attribute guidance explicitly warns that sensitive information should not be included in attributes and that high-cardinality values can create collection problems.
A happy-path endpoint test proves very little. Test duplicate requests concurrently with the same tenant and idempotency key, then assert one logical operation and one durable object. Repeat the key under a different tenant and assert isolation. Inject a generator timeout, malformed Base64 from an adapter, a valid image larger than policy, an object-store timeout after partial transmission, a ledger timeout after storage succeeds, and a client disconnect immediately after acceptance.
Then test expiry as a property rather than sleeping in a test suite: pass a controllable clock to the signer, verify that the requested lifetime is 300 seconds, and verify that refreshing access produces a new grant for the same object version. Logs should contain the operation ID and failure class, but neither the prompt, candidate fields, image data, nor signed query parameters.
Deployment needs a compatibility rule. Additive response fields are usually manageable, while changing the default response mode or renaming the image field breaks clients. Roll out URL mode behind an explicit contract version, monitor generation success separately from signing and download success, and keep the ledger schema capable of representing accepted, running, succeeded, failed, and a reconciliation state. Queue depth and age reveal scheduling pressure; per-tenant accepted and completed counts reveal whether a noisy tenant is consuming the worker pool.
Durability claims need precision. If the scorecard can be regenerated from retained, authorized source data, it may use a different retention tier from the hiring decision record. If regulations or business rules require exact preservation of what a reviewer saw, retain the immutable object version and digest for that policy period. A signed URL is temporary. The referenced bytes do not have to be.
Record the rejected default and its valid use case
Base64 is valid when the transport must be self-contained: a bounded queue message, an offline test fixture, or a client that cannot perform a second authenticated fetch. Its use case should state a maximum decoded size, a maximum encoded request or response size, logging exclusions, and timeout behavior. Without those limits, a convenience option becomes an application-memory and observability problem.
I reject it as the default here because tenant-level accountability continues after generation. The trade-off I am making is extra storage and access-control machinery in exchange for separately observable generation, persistence, and delivery. Reviewers may download an image more than once, signed access may be refreshed, retention may vary by tenant policy, and delivery failures should not trigger another model invocation. A durable object separates those events cleanly. It also lets the API return metadata without pushing image bytes through every middleware layer that touches JSON.
The conclusion is conditional but firm: use signed URLs for the e-commerce hiring scorecard service when durable storage and per-tenant usage attribution are requirements. Use Base64 only when a self-contained response is a real integration constraint and the payload is strictly bounded. Keep the rubric data authoritative, the generated image immutable, the access grant temporary, and the ledger honest about partial failure.
References
- RFC 4648, Base-N Encodings: https://www.rfc-editor.org/rfc/rfc4648
- OWASP, Authorization Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- OWASP, File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- OpenTelemetry, Attribute naming and sensitivity guidance: https://opentelemetry.io/docs/specs/semconv/general/attribute-naming/
- OpenAI, Batch API guide: https://platform.openai.com/docs/guides/batch
- LiteLLM, open-source gateway repository: https://github.com/BerriAI/litellm
Top comments (0)