---
title: "File uploads"
description: "Upload files through one interface over getqr.id, S3 or local disk. Every file is scanned, private to its owner and read through signed, short-lived URLs."
section: "interfaces"
status: "Building for 0.3 · ships as a stable API"
spec: "files-share"
canonical: "https://mainsdk.dev/docs/interfaces/files"
updated: "2026-10-28"
---

# File uploads

> Upload files through one interface over getqr.id, S3 or local disk. Every file is scanned, private to its owner and read through signed, short-lived URLs.

Status: Building for 0.3 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/files · Updated: 2026-10-28

`files` stores what your users upload and returns it through short-lived signed URLs. Every file is scanned and private to its owner. Reach for it whenever a user attaches a photo or a document.

> **Note:** Built: the `getqr`, `s3`, `local` and `memory` drivers. Planned: the `uploadthing` and `vercel-blob` drivers (naming one today fails with `MAIN-FILES-0014`) and a `files.raw()` escape hatch.

## At a glance

- Status: Building for 0.3 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/files-share.md
- Category: Files, sharing and media

Calls:

- `files.upload()`
- `files.uploadUrl()`
- `files.complete(id, { owner })`
- `files.url()`
- `files.get(id)`
- `handleFilesRoute(files, req)`
- `signatureScan(bytes)`
- `files.accept(idOrKey, token, { bytes, type, signal? })`
- `files.checkReadUrl(url)`
- `files.read(path)`
- `files.scanResult(id, verdict)`
- `files.sign(s)`
- `files.verify(s, sig)`
- `files.delete(ref, { owner })`
- `files.driver`

In main.yaml:

```yaml
files:
  driver: s3
  bucket: acme-uploads
  region: eu-west-1
  key: secret:S3_SECRET_ACCESS_KEY
  scan: true
  maxSize: 25MB
```

Guarantees, whatever the driver:

- Files readable only by their owner or whom access allows
- Scanned before they become readable
- Resumable, idempotent uploads
- Browser uploads go straight to the store on a signed, single-use URL
- Every upload goes through files, never a storage SDK in your route code


## Drivers

Pick one in main.yaml with `files.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| getqr.id | `getqr` | yes |  |
| Amazon S3 and S3-compatible stores (R2, MinIO, B2) | `s3` |  |  |
| Uploadthing | `uploadthing` |  |  |
| Vercel Blob | `vercel-blob` |  |  |
| Local files on disk | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-FILES-0010` | check | A storage SDK used for user uploads outside a `files` driver, for example `@vercel/blob` in route code |
| `MAIN-FILES-0016` | check | The configured `files` driver cannot meet a guarantee, named by its id in the message (a warning, so the run still passes): `uploadthing` declares `upload-single-use` and `data-region` |
| `MAIN-FILES-0002` | runtime | `access` denied this user a file URL (403), or someone other than the uploader called `files.complete()` |
| `MAIN-FILES-0005` | runtime | `files.complete()` ran the scan and found an infected file; the upload fails and the file is quarantined, with no readable URL |
| `MAIN-FILES-0003` | runtime | A per-call `maxSize` is above `files.maxSize` |
| `MAIN-FILES-0006` | runtime | The file is larger than the upload allows (413); it is removed and never readable |
| `MAIN-FILES-0004` | runtime | An upload target is invalid, already used or older than 15 minutes (403) |
| `MAIN-FILES-0007` | runtime | The file's type isn't one the upload allows (415); it is removed and never readable |
| `MAIN-FILES-0008` | runtime | `files.complete()` ran before any bytes reached the store (409) |
| `MAIN-FILES-0011` | runtime | The s3 driver found no credentials: no keys in `main.yaml` and no runtime role |
| `MAIN-FILES-0012` | runtime | `files.upload()` from the server isn't supported by the s3 driver yet |
| `MAIN-FILES-0013` | runtime | The store answered with an error (502) |
| `MAIN-FILES-0014` | runtime | `files.driver` names a driver that isn't built yet |
| `MAIN-FILES-0015` | runtime | The getqr.id API has no endpoint for this step, such as downloading or deleting stored bytes (501) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| files@1 contract on the getqr.id OpenAPI | 1 | 0.3 | `files-share#contract` |
| Upload and signed URLs with owner scope and scanning | 1 | 0.3 | `files-share#upload` |
| Resumable, idempotent uploads | 1 | 0.3 | `files-share#resumable` |
| Upload component from @getqr/react | 1 | 0.3 | `files-share#react` |
| Check against storage SDKs used directly | 1 | 0.3 | `files-share#check-rule` |
| Local driver | 1 | 0.3 | `files-share#local-driver` |
| Direct browser uploads on signed URLs | 1 | 0.3 | `files-share#direct-upload` |
| S3 and S3-compatible driver | 1 | 0.3 | `files-share#driver-s3` |
| Uploadthing driver | 1 | 0.3 | `files-share#driver-uploadthing` |
| Vercel Blob driver | 1 | 0.3 | `files-share#driver-vercel-blob` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

Point `files` at a store, then upload and read. This runs on the `local` driver with no account.

```yaml title="main.yaml"
files:
  driver: local
  max-size: 5MB
```

TypeScript:

```ts
import { auth, files } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  const file = (await req.formData()).get("file") as File;

  const ref = await files.upload(new Uint8Array(await file.arrayBuffer()), {
    owner: session.user,
    type: file.type,
    label: "personal",
  });
  const url = await files.url(ref, { for: session.user, expiresIn: "1h" });
  return Response.json({ ref, url });
}
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
use mainsdk::files::Label;
let file = app.files().upload(b"hello".to_vec()).owner("u1").label(Label::Pii).await?;
assert_eq!(file.size, 5);
Ok::<_, MainError>(())
}).await?;
```


`files.upload()` takes the bytes and their MIME `type`, scans them, and returns a `FileRef` with `status: "ready"`. The URL is signed and stops working after an hour. For anything large, let the browser send the file with `files.uploadUrl()` (below).

## Configuration

Every key lives under `files:`. With no `driver`, the app uses `local`.

```yaml title="main.yaml"
files:
  driver: getqr                  # getqr | s3 | local   (uploadthing, vercel-blob: planned)
  key: secret:GETQR_API_KEY      # the driver's secret, always a secret reference
  scan: true                     # scan every upload before it becomes readable
  max-size: 25MB                  # largest file accepted
  types: [image/*, application/pdf]   # allowed MIME types; empty allows any
```

| Key | Type | What it does |
| --- | --- | --- |
| `driver` | string | `getqr`, `s3` or `local`. `uploadthing` and `vercel-blob` are planned |
| `key` | secret reference | The driver's secret. A literal value fails validation with `MAIN-CONFIG-0004` |
| `scan` | boolean | On by default. `files.complete()` scans before the file is readable; an infected file is quarantined and the upload fails |
| `max-size` | size | Largest upload accepted, default `25MB`. A call may ask for less, never more (`MAIN-FILES-0003`) |
| `types` | list of MIME types | Types an upload accepts unless a call passes its own `types`. `image/*` matches a family |
| `signing-key` | secret reference | Signs upload tokens and read URLs. Set it when several instances serve the same files; otherwise each process has its own |
| `base-url` | URL | `getqr` only: the getqr.id API origin, for a sandbox |
| `bucket`, `region`, `endpoint`, `prefix` | string | `s3` only: the bucket, its region (default `us-east-1`), the endpoint of an S3-compatible store (`{bucket}` is replaced) and a key prefix |
| `access-key-id` | string | `s3` only: the access key identifier (not a secret). Set it with `key`. With both unset, the driver uses the runtime's IAM role |

Secret references match `secret:[A-Z0-9_]+` and resolve through [`secrets`](https://mainsdk.dev/docs/interfaces/secrets) on the server.

### Config for each driver

```yaml title="getqr.id (default)"
files: { driver: getqr, key: secret:GETQR_API_KEY, scan: true, max-size: 25MB }
```

```yaml title="Amazon S3 or an S3-compatible store (R2, MinIO, B2)"
files: { driver: s3, bucket: acme-uploads, region: eu-west-1, access-key-id: AKIA…, key: secret:S3_SECRET_ACCESS_KEY, scan: true }
# on AWS, omit access-key-id and key to use the runtime's IAM role
# R2, MinIO and B2 also set: endpoint: https://<their-endpoint>
```

```yaml title="Local disk (development and tests)"
files: { driver: local }   # stores under .mainsdk/files
```

Setup for each store is on its page: [getqr.id](https://mainsdk.dev/docs/integrations/getqr-id) and [Amazon S3](https://mainsdk.dev/docs/integrations/amazon-s3).

## Choosing a driver

| Driver | When to pick it | Notes |
| --- | --- | --- |
| `getqr` | The default, and the one HubXero products use. You want uploads, share links and QR codes in one place. | Needs a getqr.id API key. getqr.id scans server side, so `files.complete()` reads its verdict. The server cannot read the bytes back (`files.read()` gives `MAIN-FILES-0015`); serve them with `files.url()`. The upload target is a tus endpoint (PATCH), so the browser needs a tus-aware client. |
| `s3` | You already have a bucket, need its region, or want files in your own cloud account. Works with R2, MinIO and B2. | Keep Block all public access on. Browser uploads need a CORS rule on the bucket. Send files with `files.uploadUrl()`; a server-side `files.upload()` gives `MAIN-FILES-0012`. |
| `local` | Development and tests, with no account. | Not for production: `mainsdk check --env prod` fails with `MAIN-CHECK-0026`. |
| `uploadthing`, `vercel-blob` | Planned. | Not built: `MAIN-FILES-0014`. |

Every driver keeps the same guarantees (ADR-0013): the SDK enforces owner scope, signed single-use upload targets, the scan and short-lived read URLs; drivers only move bytes.

### Overlays by environment

Use the local driver in development so you need no account with any store:

```yaml title="main.env.dev.yaml"
files:
  driver: local
```

See [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml) for overlay order and merge rules.

## What you get

`files` stores the files your users upload and gives them back through short-lived signed URLs. You choose the store in `main.yaml`: getqr.id (the default), your own S3 bucket or an S3-compatible store, or local disk for development. Whichever you choose, uploads go through `files`, are scanned before anyone can read them, and belong to their owner: only the owner, or someone [`access`](https://mainsdk.dev/docs/interfaces/access) allows, can read one. An idempotency key makes a retried upload return the same file. Reach for it whenever a user attaches a photo, a document or a proof video. `mainsdk check` fails if route code uses a storage SDK directly.

## Examples

### Resumable upload with an idempotency key

Pass an idempotency key. A retry with the same key and owner returns the same file instead of a second one.

```ts title="app/claims/actions.ts"
"use server";
import { auth, files } from "mainsdk";
import type { FileRef } from "mainsdk/files";

declare function saveProof(user: string, claimId: string, proof: FileRef): Promise<void>;   // your own record

export async function attachProof(req: Request, claimId: string, video: Uint8Array) {
  const session = await auth.require(req);
  const ref = await files.upload(video, {
    owner: session.user,
    type: "video/mp4",
    label: "personal",
    idempotencyKey: `claim:${claimId}:proof`,   // same key on retry
  });
  await saveProof(session.user, claimId, ref);
  return ref;
}
```

### Upload straight from the browser

For large files, let the browser send the bytes to the store and keep them off your server. `files.uploadUrl()` returns an `Upload`: `method` (`PUT` or `POST`), a signed single-use `url`, `headers` or `fields`, and a pending `FileRef` in `upload.ref`. The file is not readable until `files.complete()` has scanned it. `files.complete()` takes the owner too, and refuses anyone but the uploader with `MAIN-FILES-0002`. An upload target works once and expires in 15 minutes.

```ts title="app/api/uploads/route.ts"
import { auth, files } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  const upload = await files.uploadUrl({
    owner: session.user,
    maxSize: "5MB",
    types: ["image/png", "image/jpeg"],
  });
  return Response.json(upload);
}
```

```ts title="app/api/uploads/[id]/complete/route.ts"
import { auth, files } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req);
  const { id } = await params;                                   // upload.ref.id
  const ref = await files.complete(id, { owner: session.user }); // only the uploader can finish it
  const url = await files.url(ref, { for: session.user, expiresIn: "1h" });
  return Response.json({ url });
}
```

The browser sends the file to `upload.url` with `upload.method`: for `PUT`, the bytes as the body with `upload.headers`; for `POST`, a form with `upload.fields` first and the file last. Then it calls the complete route with `upload.ref.id`. The store refuses a file over `maxSize` (`MAIN-FILES-0006`, 413) or outside `types` (`MAIN-FILES-0007`, 415), and a used or expired target fails with `MAIN-FILES-0004`. The full browser function is in [Upload files with S3 or getqr.id](https://mainsdk.dev/docs/guides/file-uploads).

### Upload from a form with the Upload component

With the getqr.id driver, `<Upload/>` from `@getqr/react` handles the browser side (drop zone, paste, camera, progress and the scan) and shows "Powered by getqr.id". Its props belong to `@getqr/react`.

```tsx title="app/claims/new/upload-proof.tsx"
"use client";
import { Upload } from "@getqr/react";

export function UploadProof() {
  return <Upload accept={["video/*", "image/*"]} onError={(err) => console.error(err)} />;
}
```

### Let a reviewer read a file

`files.url()` checks [`access`](https://mainsdk.dev/docs/interfaces/access) for the reader. If the decision denies them, it throws `MAIN-FILES-0002` with status 403. The action is `files.read`, so a role rule such as `support: [files.read]` lets support staff read any file.

```ts title="app/api/claims/[id]/proof/route.ts"
import { auth, files } from "mainsdk";
import type { FileRef } from "mainsdk/files";

declare function proofOf(user: string, claimId: string): Promise<FileRef>;   // your own record

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const session = await auth.require(req, { role: "reviewer" });
  const proof = await proofOf(session.user, id);
  const url = await files.url(proof, { for: session.user, expiresIn: "1h" });
  return Response.redirect(url, 302);
}
```

### Read the bytes on the server

`files.read(ref, { for })` returns `{ bytes, type }` for the owner or an allowed reader, for server code that processes a file (for example [`documents`](https://mainsdk.dev/docs/interfaces/documents)). It needs a store that serves bytes to the server: `local` and `s3` do, `getqr` does not (`MAIN-FILES-0015`).

```ts title="lib/read-file.ts"
import { files } from "mainsdk";

export async function textOf(fileId: string, userId: string) {
  const { bytes, type } = await files.read(fileId, { for: { id: userId } });
  return { type, text: new TextDecoder().decode(bytes) };
}
```

### Look up a file

`files.get(id)` returns a file's current record (status, owner, size, type) or `undefined` for an unknown id. It is synchronous and never returns the bytes.

```ts title="lib/file-status.ts"
import { files } from "mainsdk";

export function isReady(fileId: string) {
  return files.get(fileId)?.status === "ready";
}
```

## Reference: driver and route calls

These calls sit under the upload and read flow. Your routes seldom need them, but drivers, adapters and tests do.

| Call | What it does |
| --- | --- |
| `files.accept(idOrKey, token, { bytes, type, signal? })` | Accept the bytes for an upload target from `files.uploadUrl()`. It checks the token, the expiry, single use, the size and the type, and rejects an aborted `signal`. The positional form `accept(id, token, bytes, type)` is deprecated and goes in 1.0. |
| `files.scanResult(id, verdict)` | Record a store's scan verdict that arrived by webhook (getqr.id `upload.scanned`), so `files.complete()` needn't poll. `{ status: "ready", size?, type? }` makes the file readable when its size and type fit. `{ status: "blocked" }` quarantines it. Returns the file's tenant, or `undefined` when no such file is pending. |
| `files.checkReadUrl(url)` | Check a local read URL's signature and expiry. Returns the file id when it is valid, else `null`. |
| `files.sign(s)` | Sign a string with the files key (HMAC-SHA256, base64url). |
| `files.verify(s, sig)` | Compare a signature made by `files.sign` in constant time. |
| `files.delete(ref, { owner })` | Delete a file and its stored bytes. `ref` is a `FileRef` or an id, and only the file's owner may delete it. The decision is written to the audit log. |
| `files.driver` | The driver object behind the interface, read-only. Its `id` is the driver name. For adapters and tests. |
| `handleFilesRoute(files, req)` | Serve the local and memory drivers' routes under `/_mainsdk/files`: `PUT .../upload` accepts bytes, and `GET .../read` returns them for a valid signed URL. Any other request gets 404. Adapters mount it for you. |
| `signatureScan(bytes)` | The built-in scan. It resolves `"infected"` for the EICAR test signature and `"clean"` otherwise. Pass a real engine as `scanner` to replace it. |

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-FILES-0002` | `files.url()` or `files.read()` for a reader `access` denies, or `files.complete()` by someone other than the uploader (403) | Read as the owner, or add an access rule for the reader |
| `MAIN-FILES-0003` | A call's `maxSize` is above `files.maxSize` (400) | Lower it in the call, or raise `files.maxSize` on purpose |
| `MAIN-FILES-0004` | An upload target is invalid, already used or older than 15 minutes (403) | Ask the server for a new one with `files.uploadUrl()` |
| `MAIN-FILES-0005` | The scan found an infected file; the upload fails, the file is quarantined and no URL exists | Ask the user for a clean file |
| `MAIN-FILES-0006` / `0007` | The file is larger than the upload allows (413), or its type is not allowed (415) | Send a smaller file, or one of the allowed types |
| `MAIN-FILES-0008` | `files.complete()` ran before any bytes reached the store (409) | Send the file to `upload.url` first |
| `MAIN-FILES-0010` | `mainsdk check` found a storage SDK imported directly in route code, for example `@aws-sdk/client-s3` | Replace it with `files.upload()` or `files.uploadUrl()`, or set that store as the `files` driver |
| `MAIN-FILES-0011` to `0015` | Driver problems: no S3 credentials (`0011`), server upload on S3 (`0012`), the store answered with an error (`0013`), a driver not built (`0014`), a step the getqr.id API lacks (`0015`) | The `fix` in the problem names the change |

The full list is in [check codes](https://mainsdk.dev/docs/reference/check-codes). Run `mainsdk explain MAIN-FILES-0005` for the cause and fix offline.

## Swapping drivers

Switch one line; `files.upload()`, `files.uploadUrl()`, `files.complete()`, `files.url()` and `files.read()` stay the same.

```yaml title="main.yaml"
files:
  driver: s3   # was getqr
```

Existing files stay where they are until you migrate them. Sharing is separate from storage: `share()`, links and QR codes always use getqr.id, whichever `files` driver holds the file (see [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share)).

## Testing

`createTestApp()` puts `files` on its in-memory fake. The fake enforces the same owner scope and scanning as production, and `testApp.files.put(upload, bytes, { type })` plays the browser's part in the direct flow. The built-in scan refuses the EICAR test file.

```ts title="app/api/uploads/uploads.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { files } from "mainsdk";

describe("files", () => {
  it("files-share AC1 refuses a reader access denies", async () => {
    const testApp = createTestApp({ main: "./main.yaml" });
    const upload = await files.uploadUrl({ owner: { id: "u1" }, maxSize: "5MB", types: ["image/png"] });
    await testApp.files.put(upload, new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { type: "image/png" });
    const ref = await files.complete(upload.ref.id, { owner: { id: "u1" } });
    await assert.rejects(files.url(ref, { for: { id: "u2" } }), { code: "MAIN-FILES-0002" });
  });
});
```

Use `clock.advance("61m")` to expire a one-hour signed URL without waiting. The infected-file test is in the [upload guide](https://mainsdk.dev/docs/guides/file-uploads).

## Security and privacy

- Files are private to their owner. Readers go through an `access` decision, and URLs are signed and short-lived.
- `files.complete()` scans each upload before it becomes readable; infected files are quarantined and audited.
- Label personal files (`label: "personal"`) so logs, errors and exports treat them as personal data.
- With tenancy, a file of another tenant is refused with `MAIN-TENANT-0005` before any owner check, and its object key carries the tenant's region.
- Browser upload targets are single-use and limited in size and type, so a client cannot send a larger or different file than you allowed.
- With the default driver you also get first-party tracking from getqr.id.

## FAQ

**Who scans the file, getqr.id or the SDK?** With the getqr.id driver, getqr.id scans server side and `files.complete()` trusts its verdict. With the other drivers the SDK scans in `files.complete()`: the built-in scan matches the EICAR test signature, and a real engine plugs in through the `scanner` option when you build the app in code.

**Can I keep using S3?** Yes, as the `files` driver. What fails `mainsdk check` with `MAIN-FILES-0010` is importing its SDK in route code instead of going through `files`.

**How do I share a file with someone outside the app?** Use [`share()`](https://mainsdk.dev/docs/interfaces/share) to make a private, expiring getqr.id link.

## Next

- [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share): share a file or record as an expiring link.
- [Images and video](https://mainsdk.dev/docs/interfaces/media): resize and transcode uploads (planned).
- [Permissions](https://mainsdk.dev/docs/interfaces/access): decide who else can read a file.
