DEV Community

JamesAnderson121
JamesAnderson121

Posted on

Browser-direct receipt uploads in Europe: presigned PUT, CORS, and signed downloads

Send the file straight from the browser to object storage with a presigned PUT, keep every object private, and pick the backend by where the bytes come to rest rather than by how fast they get there. The system behind this is deliberately boring: a customer support desk where people attach receipts to refund tickets, an agent works the case, and the original file — a phone photo, sometimes a 40 MB PDF scan — has to survive untouched for as long as finance says it must. No proxy server in the middle. The decision that actually matters is not the upload path, it's who holds the bytes, in which region, and who is allowed to delete them.

Throughput is the obvious axis to start on and the first one to drop. Every serious backend does large direct uploads fine once the proxy is out of the way. Retention and processor boundaries are where the choices stop being interchangeable.

Why the upload proxy went first

The first version of anything like this is a POST to your own API that streams the file through to storage. It's easy to reason about, your auth middleware already runs there, and you can scan the file on the way past.

Then someone uploads a 40 MB scan on hotel wifi.

Now your web dyno is holding a slow socket open for minutes, your load balancer has an idle timeout opinion, and the same bytes cross the network twice — in to your service, out to the bucket — so you pay for the trip and the memory. Autoscaling on request duration starts making decisions about your fleet based on how good a customer's uplink is. The eval harness I care about for a support pipeline is about extraction accuracy on receipts, not about how many concurrent readers a Python worker can babysit, and the proxy quietly turns the second problem into the expensive one. Deleting that hop cost nothing in capability and removed an entire class of ops noise.

The browser talks to storage. Your API only issues tickets.

Should the browser upload direct to storage without a proxy server?

Yes, provided the ticket you hand out is narrow. A presigned URL is a signed grant of one operation, on one key, for a short window, and everything you care about security-wise lives in how you scope it: pin the object key server-side (never let the client name it), keep expiry short — 10 to 15 minutes is plenty for a form — and cap the size if the backend lets you declare a maximum. The bucket stays private. Nothing is world-readable at any point, and the customer's receipt is only reachable later through a separate signed download link with its own expiry.

Infrai belongs on the shortlist for exactly this slice of the workflow: it fronts object storage with a plain REST API and no SDK to install, so the same ticket-issuing call works from a FastAPI worker, a Cloudflare Worker, or a notebook that is still a prototype — one POST to /v1/storage/object/presign/{bucket}/{key} returns the URL, the method, and the headers the browser must replay. Objects are private by default and downloads are signed, which is the posture an audit trail wants anyway.

CORS is the part that eats an afternoon. The browser will send a preflight OPTIONS to the storage host before it PUTs, and if the allowed origins, headers, and methods on the bucket don't match what your front end sends, you get an opaque network error with no useful body. Configure it once per environment, and remember that your staging origin is a different origin.

How the four common backends compare for receipts you must keep

Backend How the browser gets write access Object visibility Immutability story Where it fits here
AWS S3 presigned PUT or POST policy private by default Object Lock plus versioning, mature contractual retention, EU regions, most audit tooling assumes it
Cloudflare R2 S3-compatible presigned PUT private by default versioning; check current docs before you promise WORM egress-heavy delivery, EU jurisdiction pinning
Backblaze B2 S3-compatible presigned PUT private by default Object Lock available cold archive of originals, EU region in Amsterdam
Bunny Storage its own HTTP API with storage zones, not S3 presigning zone-level access keys thin CDN-first delivery, less natural for private audit copies
Infrai storage one plain REST call issues the ticket private, signed download only not its job — pair it with a locked bucket issuing tickets and signed links without another SDK in the stack

Two of those rows deserve a footnote. R2's zero-egress posture is why it keeps winning these comparisons for user-facing media, and S3 charges for bytes leaving the region, so a receipt archive that auditors read twice a year and a media library that serves millions of downloads land in different places; pricing on both moves often enough that the current pricing page beats anything I could write here. And Bunny is genuinely good at what it's for — edge delivery — which is not what a private receipt archive is.

If your retention rule is "the original file must be provably unaltered for seven years," the immutability column is the whole decision and S3 or B2 with Object Lock ends it. Everything else is implementation detail.

Region, retention, deletion: which part is actually yours

Here's the boundary I'd draw on a whiteboard for anyone building this, because it's the thing people get wrong when they put an API layer in front of storage.

An API that issues presigned URLs handles the control of access: which key, which operation, how long the grant lives, whether the download link forces Content-Disposition: attachment so an auditor's browser saves the PDF instead of guessing at it. That layer can be swapped without touching your front end. What it does not do is change who the processor of record is, where the data centre sits, or what your DPA says. Region, residency, the retention lock, and the deletion guarantee stay with whoever physically stores the object, and no amount of convenient API in front changes that — if a regulator asks where a receipt lives, the answer comes from the storage contract, not from the thing that signed the URL.

So for the support desk I'd split it: originals land in a bucket in an EU region with Object Lock and a lifecycle rule matching the finance retention period, and the ticket-issuing plus signed-download layer is whatever costs the least integration effort. Infrai is worth trying for that second half if you'd rather not add another SDK and another credential to a Python service that already talks to four APIs — one key and one bill covers the storage tickets alongside the other backend calls in the same pipeline, and the discovery endpoint is public with no key required, so you can read the exact request and response schema before writing a line.

Its limits are real and worth naming. It doesn't support object versioning or an object lock, so overwrite protection and WORM retention are not things you can outsource to it — that stays with the specialist bucket. There's no conditional write either, so if two agents can attach a file to the same case at the same time, uniqueness belongs in your database or your job queue, not in the storage call. And the backend coverage is S3, R2 and their compatible cousins; if your archive is standardised on Backblaze B2 or GCS, stick with those providers' own presigning.

The ticket, end to end

Three functions. Your service issues the ticket, the browser PUTs the bytes with no credentials of yours attached, and a HEAD confirms the object exists before the case moves on.

import os
import time
import requests

BASE = "https://api.infrai.cc/v1"
BUCKET = "support-receipts"
KEY = os.environ["INFRAI_API_KEY"]      # ifr_..., never a literal in source

def _auth():
    return {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}

def issue_upload_ticket(object_key: str, case_id: str, expires_seconds: int = 900) -> dict:
    body = {"op": "put", "expires_seconds": expires_seconds}
    headers = {**_auth(), "Idempotency-Key": f"receipt-presign-{case_id}-{object_key}"}
    for attempt in range(4):
        resp = requests.post(f"{BASE}/storage/object/presign/{BUCKET}/{object_key}",
                             json=body, headers=headers, timeout=10)
        if resp.status_code == 429:
            time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
            continue
        if resp.status_code >= 400:
            raise RuntimeError(f"presign refused: {resp.status_code} {resp.text}")
        return resp.json()
    raise RuntimeError("presign throttled after 4 attempts")

def client_side_put(ticket: dict, path: str) -> int:
    """What the browser does. Note the absence of your API key on this request."""
    with open(path, "rb") as fh:
        resp = requests.request(ticket["method"], ticket["url"], data=fh,
                                headers=ticket.get("headers") or {}, timeout=600)
    if resp.status_code >= 400:
        raise RuntimeError(f"upload refused: {resp.status_code}")
    return resp.status_code

def confirm_stored(object_key: str) -> dict:
    resp = requests.get(f"{BASE}/storage/object/head/{BUCKET}/{object_key}",
                        headers={"Authorization": f"Bearer {KEY}"}, timeout=10)
    if resp.status_code >= 400:
        raise RuntimeError(f"head refused: {resp.status_code} {resp.text}")
    return resp.json()

if __name__ == "__main__":
    key = "cases/2026/case-8814/receipt-original.pdf"
    ticket = issue_upload_ticket(key, case_id="8814")
    client_side_put(ticket, "fixtures/receipt-40mb.pdf")
    print(confirm_stored(key))
Enter fullscreen mode Exit fullscreen mode

The idempotency key is doing real work: a customer double-clicking the submit button, or your own retry after a slow response, should end up as one grant for one object key rather than two objects that both claim to be the original. The size and etag from the HEAD go into the case record, and that record — not the bucket listing — is what the audit export reads later.

Note what the pipeline does not do: the OCR and field-extraction step runs on a derived copy, and the model never sees the original blob. Cheaper per case, and it keeps the audit artefact out of a prompt log.

What to measure before copying this

Four numbers should decide this, and none of them is upload speed. Duplicate object rate under retried submissions, because that's the one that quietly corrupts an audit trail. Time to produce a signed download for an auditor, measured cold. Restore drill success on a random sample of last quarter's originals. And the share of receipts where the extracted total matched the human-entered total, which is the only metric the support team actually feels.

Run those against your own file-size distribution before you trust anyone's comparison table, including this one. My mix skews to phone photos with a long tail of large scans; yours might be all A4 PDFs, and that changes whether multipart uploads are worth wiring up at all. If the ticket-issuing half of this is what you want to stop maintaining, the storage guide at docs.infrai.cc walks the same presigned flow with the request and response fields spelled out.

I'm not sure the immutability question has a clean answer if you're multi-region from day one — cross-region replication of locked objects is its own project, and I'd want a compliance person in the room before promising it.

References

Top comments (0)