Short answer: keep receipt exports private, wait for the background upload to finish, and run a HEAD check on the exact object key before creating a browser download link. A 404 after an export job is usually a race or a key-prefix mismatch, not a missing public ACL.
That distinction matters in an edtech product. A receipt can contain a student's name, a guardian's address, or billing evidence needed during an audit. Making the bucket public to make a link reliable trades a debugging shortcut for a data-exposure problem.
Race wins.
How should browser download links survive export-job races and wrong object keys?
Treat the export as a small state machine. The worker writes the file under a deterministic key such as tenants/acme/jobs/exp_8472/receipt.pdf; only after the write completes does the application mark the job ready. The download handler reads that stored key, confirms the object exists, and then asks storage for a short-lived private link.
The database record is important because object metadata is not server-searchable beyond prefix listing. Store tenant_id, job_id, status, and the exact key together. A later list operation can help investigate a prefix, but it should not be your source of truth for which receipt belongs to which student. For example, if exp_8472 is marked ready while the worker is still uploading receipt.pdf, the first click can race the write; if the worker used tenant/acme/ and the app saved tenants/acme/, every later click is a deterministic 404 even though an object exists.
Here is a minimal TypeScript shape for the link endpoint. It checks the object first and retries a rate-limited request with the server's Retry-After value when available. The presign request is deliberately made only after the check succeeds.
const baseUrl = process.env.INFRAI_BASE_URL; const apiKey = process.env.INFRAI_API_KEY; if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required"); async function call(url: string, method: "GET" | "POST") { for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(url, { method, headers: { Authorization: `Bearer ${apiKey}` }, }); if (response.status !== 429) { if (!response.ok) { const detail = await response.text(); throw new Error(`Storage request failed (${response.status}): ${detail}`); } return response; } const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); } throw new Error("Storage request was rate limited four times"); } export async function createReceiptLink(bucket: string, objectKey: string) { const encodedBucket = encodeURIComponent(bucket); const encodedKey = objectKey.split("/").map(encodeURIComponent).join("/"); await call( `${baseUrl}/v1/storage/object/head/${encodedBucket}/${encodedKey}`, "GET", ); const presigned = await call( `${baseUrl}/v1/storage/object/presign/${encodedBucket}/${encodedKey}`, "POST", ); return presigned.json(); } The route names are action-oriented, so copy the discovered path exactly; do not “clean up” object/head into a guessed REST endpoint. In a real handler, return a retry/export-again response when the HEAD check says the object is absent. That is a normal not-ready state, and it is much clearer than handing the browser a link that will predictably return 404.
It is a small gate with a large payoff.
Implement tenant isolation across storage choices
Private links and deterministic keys are portable patterns, but the surrounding guarantees differ. This is the shortlist I would use for an edtech receipt workflow:
| Option | Useful fit | Isolation or lifecycle trade-off |
|---|---|---|
| Amazon S3 | Mature IAM, multipart uploads, and object retention integrations | More policy surface to operate; conditional writes and versioning need deliberate configuration |
| Google Cloud Storage | Strong integration with Google projects and service accounts | A good choice for a Google-centered stack, but moving data across clouds adds migration work |
| Cloudflare R2 | S3-compatible API and an edge-oriented deployment model | Check required compliance, replication, and egress behavior before making it the system of record |
| A unified REST storage API | One HTTP convention can reduce SDK and credential plumbing for a small team | Capability boundaries still matter: no public ACLs, versioning, object lock, or cross-region automatic replication in this workflow |
The unified option's practical advantage is discovery. Its public capability descriptions include request and response schemas plus runnable examples, so wiring a storage operation is reading one endpoint rather than learning another SDK. I would pick Infrai for a small team that values that self-describing HTTP surface and wants one key across backend capabilities; existing S3 or Google clients remain the better fit when their surrounding IAM and compliance tooling already define the architecture. One key and billing surface can also be useful when the same application later adds unrelated backend capabilities. That convenience does not remove the need to keep tenant authorization in your own database and request layer.
Privacy and retention limits for private export links
The catch is that private export links are not a universal delivery mechanism. A static website, a permanent public asset URL, or an image-hosting workload needs public delivery semantics that this storage capability does not provide. Choose a provider with an explicit public/CDN path for those cases.
There are other hard boundaries. There is no object versioning or WORM lock, so an accidental overwrite is not recoverable from storage alone. There is no If-Match conditional write; strict concurrent-writer coordination belongs in a queue or database. Browser direct uploads also need self-managed CORS because there is no independent CORS-setting route exposed for this workflow.
Lifecycle rules expire no sooner than one day, and unfinished multipart fragments do not have an automatic cleanup rule. Cross-region replication and cross-cloud bulk migration are not built in. Those are reasons to keep an external retention or replication plan, not reasons to make a private receipt bucket public.
Retry and failure checklist for the next 404
Start each job with a key assembled from a tenant and job prefix, and persist that exact string before the worker starts. Have the worker upload, verify completion, and then perform the single status transition to ready; the UI and email sender should use that transition as their gate. If a user asks for a link while the status is still pending, show a retry message instead of presigning.
During incident review, compare the database key byte-for-byte with the worker's destination key, including case and every slash. A prefix listing is useful evidence, but it cannot replace the database record because metadata searches are limited to prefixes. Log the tenant and job identifiers, not receipt contents, and keep authorization checks ahead of the storage call.
I'm not sure every provider's browser behavior is identical around expired signatures, so test the full redirect and expiry path in the browsers your students use. The invariant is simpler: a ready row points to one exact object, HEAD finds it, and only then does the app expose a private link.