---
title: "File uploads"
description: "Send files with a multipart form post, or upload them straight to storage with a pre-signed URL and submit JSON. Download them through signed, expiring links."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.getquickforms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# File uploads

A [file field](https://docs.getquickforms.com/schema/#field-types) can be filled in two ways:

| | Multipart | Direct upload |
| --- | --- | --- |
| How | Post the file with the rest of the form as `multipart/form-data` to `POST /f/{form}`. | Ask for a pre-signed URL, `PUT` the file straight to storage, then submit JSON with the upload ID as the field's value. |
| Best for | Plain HTML forms. The simplest option. | Custom JavaScript or React forms that send JSON, and large files you don't want to pass through the app. The [embed widget](https://docs.getquickforms.com/embed/) and [hosted forms](https://docs.getquickforms.com/hosted-forms/) use it automatically when the disk can sign uploads. |
| Storage | Any `QUICKFORMS_UPLOADS_DISK`. | A disk that can sign uploads: an S3-compatible bucket, or the `local` disk with `serve` on. See [storage setup](#storage-setup). |

Either way, the submission keeps the same file metadata: `name`, `size` and `mime`, where `mime` is the type quickforms found by looking at the file, not the one the client sent. Files are private: wherever a file is shown, it gets a [signed download link](#download-links) that expires.

## Multipart

Give the form `enctype="multipart/form-data"` and post it to the submit endpoint as usual.

```html title="index.html"
<form action="https://YOUR-QUICKFORMS-HOST/f/FORM_ID" method="post" enctype="multipart/form-data">
  <input name="email" type="email" required>
  <input name="cv" type="file" accept=".pdf,.docx">
  <button type="submit">Send</button>
</form>
```

The file is checked against the field's `accept` and `max_size_kb` along with the rest of the [validation](https://docs.getquickforms.com/validation/). HTML, SVG and script files are refused unless the field's `accept` lists them.

## Direct uploads

A direct upload takes three requests:

1. `POST /f/{form}/uploads` with the file's details. quickforms checks them against the field and returns an upload ID and a pre-signed URL. On a form with Turnstile or Altcha, include the token too.
2. `PUT` the file to that URL with the headers it came with. The file goes straight to storage, not through the app.
3. Submit the form as JSON to `POST /f/{form}`, with the upload ID as the file field's value.

### 1. Ask for an upload URL

Send the file field's name, the file name, its size in bytes and its content type.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/f/FORM_ID/uploads \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"field": "cv", "filename": "cv.pdf", "size": 204800, "content_type": "application/pdf"}'
```

### 201 response

```json
{
  "upload_id": "upl_01jcb4m7x2k9p3q8r5t6v0w1yz",
  "url": "https://YOUR-BUCKET-ENDPOINT/incoming/01jcb4m7x2k9p3q8r5t6v0w1yz?X-Amz-Signature=...",
  "method": "PUT",
  "headers": { "Content-Type": "application/pdf" },
  "expires_at": "2026-11-06T10:29:00+00:00",
  "pass": null
}
```

The IDs, URL and time in the response are illustrative. `expires_at` is when the upload URL stops working, 5 minutes after it was issued.

Before anything is signed, quickforms checks that:

- `field` is a file field on the form.
- `filename` is given (up to 255 characters) and its extension is in the field's `accept` list, when it has one.
- `size` is a whole number of bytes, at least 1 and no more than the field's `max_size_kb` (or `QUICKFORMS_MAX_UPLOAD_KB` when the field has no limit).
- `content_type` is given.

`filename` is cleaned: directories, control characters and bidirectional text marks are removed, and long names are shortened.

On a form with Turnstile or Altcha, send the token in the request as `cf-turnstile-response` or `altcha`, the same as on the [submit endpoint](https://docs.getquickforms.com/submit/). The response then has a `pass`. Send that as `_pass` when you ask for any more upload URLs, and with the submission in place of a new token. The pass lasts `QUICKFORMS_PASS_MINUTES` minutes and works for one submission.

### 2. Upload the file

`PUT` the file body to `url`, with exactly the `headers` from the response. The `Content-Type` is part of the signature, so it must match the `content_type` you sent in step 1.

```bash
curl -X PUT "UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @cv.pdf
```

### 3. Submit the form

Send the submission as JSON, with the `upl_...` ID in place of the file.

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/f/FORM_ID \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"email": "ada@example.com", "cv": "upl_01jcb4m7x2k9p3q8r5t6v0w1yz"}'
```

quickforms checks that the upload was issued for this form and this field, hasn't been used for another submission, hasn't expired and is really in storage. Then it copies the file to its permanent place and checks that copy:

- Its size is exactly the `size` from step 1 and within the field's limit.
- Its contents match the field's `accept` list, when it has one.
- It isn't HTML, SVG or a script, unless the field's `accept` lists that type.

If a check fails the file is deleted and you need to upload it again. Each upload ID can be used once. Later `PUT`s to the upload URL can't change an attached file.

## The embed widget and hosted forms

The [embed widget](https://docs.getquickforms.com/embed/) and [hosted forms](https://docs.getquickforms.com/hosted-forms/) pick the upload method for you:

- When the uploads disk can sign uploads, each chosen file is uploaded straight to storage first (steps 1 and 2), then the form is sent as JSON with the `upl_...` IDs as the file fields' values.
- Otherwise the form is sent as `multipart/form-data`, files included.

For a form that is not published yet, the widget sends its sandbox key with the upload request too. On a form with Turnstile or Altcha, they send the check with the first file and use the returned pass for the rest and for the submission.

## Custom React or JavaScript forms

This component uploads the file when it is picked, then submits the form as JSON. It is an example to adapt: the form ID, field names and messages are illustrative.

```jsx title="ApplyForm.jsx"
import { useState } from "react";

const HOST = "https://YOUR-QUICKFORMS-HOST";
const FORM = "FORM_ID";

async function uploadFile(file, field) {
  // 1. Ask for an upload URL.
  const slotResponse = await fetch(`${HOST}/f/${FORM}/uploads`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      field,
      filename: file.name,
      size: file.size,
      content_type: file.type || "application/octet-stream",
    }),
  });
  const slot = await slotResponse.json();
  if (!slotResponse.ok) throw slot; // { message, errors } on a 422

  // 2. PUT the file straight to storage with the headers that came back.
  const put = await fetch(slot.url, { method: slot.method, headers: slot.headers, body: file });
  if (!put.ok) throw { message: "The upload failed. Please try again." };

  return slot.upload_id;
}

export default function ApplyForm() {
  const [cv, setCv] = useState(null);
  const [status, setStatus] = useState("");

  async function onFile(event) {
    const file = event.target.files[0];
    if (!file) return;
    setStatus("Uploading...");
    try {
      setCv(await uploadFile(file, "cv"));
      setStatus("");
    } catch (error) {
      setStatus(error.errors?.size?.[0] ?? error.errors?.filename?.[0] ?? error.message);
    }
  }

  async function onSubmit(event) {
    event.preventDefault();
    const email = new FormData(event.currentTarget).get("email");

    // 3. Submit as JSON with the upload ID as the file field's value.
    const response = await fetch(`${HOST}/f/${FORM}`, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify({ email, cv }),
    });
    const body = await response.json();
    setStatus(response.ok ? body.message : Object.values(body.errors ?? {}).flat().join(" "));
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <input type="file" accept=".pdf" onChange={onFile} />
      <button type="submit" disabled={status === "Uploading..."}>Send</button>
      <p>{status}</p>
    </form>
  );
}
```

## The `quickforms.upload` helper

If your page loads `embed.js`, `window.quickforms.upload(file, options)` does steps 1 and 2 for you and resolves with the upload ID. Submit the form yourself with that ID as the field's value.

```js
const id = await quickforms.upload(fileInput.files[0], { form: "FORM_ID", field: "cv" });

await fetch("https://YOUR-QUICKFORMS-HOST/f/FORM_ID", {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify({ email: "ada@example.com", cv: id }),
});
```

| Option | Description |
| --- | --- |
| `form` | The form ID. Required. |
| `field` | The file field's name. Required. |
| `sandbox` | A sandbox key (`qf_sandbox_...`), sent as the `X-Quickforms-Sandbox` header. Needed for forms that are not published yet. |
| `host` | The quickforms host. Defaults to the host `embed.js` was loaded from. |

On a form with Turnstile or Altcha, pass `turnstile` or `altcha` in the options for the first file, then send `quickforms.takePass('FORM_ID')` as `_pass` with the submission.

The file's `type` is sent as the content type, or `application/octet-stream` when the browser doesn't know it. When step 1 fails, the promise rejects with the JSON error body (`message` and, for a `422`, `errors`). When the `PUT` fails, it rejects with `{ message: "The upload failed. Please try again." }`.

## Download links

Uploaded files are not public. Wherever a file is shown, quickforms adds a `url` to the file's value: a signed link to `GET /files/{submission}/{field}` that expires.

```json title="A file value with its download link"
{
  "name": "cv.pdf",
  "size": 204800,
  "mime": "application/pdf",
  "url": "https://YOUR-QUICKFORMS-HOST/files/SUBMISSION_ID/cv?expires=1731494640&signature=..."
}
```

The query string is illustrative.

| Where | What you get |
| --- | --- |
| [Webhooks](https://docs.getquickforms.com/webhooks/) | `submission.data.<field>.url` in the event body. |
| [Email](https://docs.getquickforms.com/email/) | The notification email links the file name to the download. |
| [API](https://docs.getquickforms.com/api/) | Submission responses include a fresh link each time you fetch them. |
| [Dashboard](https://docs.getquickforms.com/dashboard/) | The submission view links each file. |
| [Destinations](https://docs.getquickforms.com/destinations/) | A file field's `{{placeholder}}` becomes its download link. |
| [Exports](https://docs.getquickforms.com/exports/) | File columns hold the download link. |

### Expiry

Links last `QUICKFORMS_FILE_LINK_DAYS` days (3 by default). Links in [exports](https://docs.getquickforms.com/exports/) last `QUICKFORMS_EXPORT_FILE_LINK_DAYS` days (7 by default). After that the link returns a `403`. Fetch the submission from the API for a new one.

### Downloading

- A tampered or expired link returns a `403`. A link for a field with no stored file, a file that is gone from storage, or a form or team that has been deleted returns a `404`.
- On an `s3` disk (such as R2 or S3), the link redirects to a storage URL that works for 5 minutes and downloads the file under its original name.
- On other disks, such as `local`, the file is sent straight back as a download with its original name and type.
- Download links are rate limited to 60 requests a minute.

Deleting a submission deletes its stored files too. Deleting a form or team takes its forms offline straight away. Its submissions and files, then the form or team itself, are permanently deleted after `QUICKFORMS_DELETED_GRACE_DAYS` days (30 by default) by `quickforms:purge-deleted`, which runs daily from the scheduler.

## Sandbox and unpublished forms

`POST /f/{form}/uploads` follows the same rules as the submit endpoint:

- A form that has never been published only issues upload URLs with the form's sandbox key in the `X-Quickforms-Sandbox` header. Without it the response is a `404`.
- With the sandbox key, the field is checked against the form's [draft](https://docs.getquickforms.com/drafts-and-publishing/), if it has one.
- A form that is switched off gets a `410`.

## CORS

The uploads endpoint uses the form's [allowed origins](https://docs.getquickforms.com/submit/#cors), like the submit endpoint, and answers preflight `OPTIONS` requests.

The `PUT` in step 2 goes to your storage, not to quickforms, so for browser uploads to a bucket such as Cloudflare R2 or Amazon S3 the bucket needs its own CORS rule: allow `PUT` from your site's origin, with the `Content-Type` header. Set this in your storage provider's bucket settings.

## Limits

| Limit | Value |
| --- | --- |
| File size | The field's `max_size_kb`, which can't exceed `QUICKFORMS_MAX_UPLOAD_KB` (10240 KB by default). Checked when the URL is issued and again on the stored file when you submit. |
| File type | The field's `accept` extensions, checked against `filename` when the URL is issued and against the file's contents when you submit. |
| Upload URL | Works for 5 minutes. |
| Upload ID | Must be used in a submission within 2 hours, and only once. |
| Rate limit | 60 upload requests a minute per IP address, and `QUICKFORMS_FORM_LIMIT_UPLOADS` a minute for the whole form. |
| Waiting uploads | `QUICKFORMS_MAX_PENDING_UPLOADS` unused upload IDs per form (500 by default). |
| Team storage | `QUICKFORMS_TEAM_STORAGE_MB` per team (10240 by default), counting stored files and unused upload IDs. |

## Errors

### Asking for an upload URL

| Status | When | Message |
| --- | --- | --- |
| `404` | Unknown form, or the form is not published and there is no sandbox key | `{"message": "This form has not been published yet."}` for unpublished forms |
| `403` | Origin not allowed | `{"message": "This origin is not allowed to submit to this form."}` |
| `410` | Form switched off | `{"message": "This form is not accepting submissions."}` |
| `422` | `field` is not a file field | On `field`: "There is no file field with that name on this form." |
| `422` | File too large | On `size`: "The file must not be larger than 4096 KB." (with the field's limit) |
| `422` | Extension not in `accept` | On `filename`: "The file must be one of these types: pdf, docx." (with the field's list) |
| `422` | The uploads disk can't sign uploads | On `field`: "Direct uploads are not available on this storage disk. Send the file as multipart/form-data instead." |
| `422` | Missing or failed Turnstile or Altcha check | On `cf-turnstile-response` or `altcha` |
| `429` | Rate limit hit | `{"message": "Too many uploads. Please wait a minute and try again."}` |
| `429` | Too many unused upload IDs on the form | `{"message": "This form has too many uploads in progress. Please try again later."}` |
| `429` | Team storage budget used up | `{"message": "This form cannot take any more files right now."}` |

### Submitting with an upload ID

These come back as a `422` in the usual [validation error](https://docs.getquickforms.com/validation/) shape, on the file field:

| When | Message |
| --- | --- |
| The ID is unknown, for another form or field, already used or expired | "That upload was not found, has already been used or has expired. Upload the file again." |
| The file was never uploaded | "The file was not uploaded. PUT it to the upload URL before submitting." |
| The stored file is larger than the field allows | "The file must not be larger than 4096 KB." (with the field's limit) |
| The stored file isn't the size given in step 1 | "The file does not match the size given when the upload started. Please upload it again." |
| The file's contents aren't in the field's `accept` list | "The file must be one of these types: pdf, docx." (with the field's list) |
| The file is HTML, SVG or a script and the field doesn't accept that | "This type of file is not allowed." |

## Cleanup

`quickforms:prune-uploads` runs every hour from the scheduler. It deletes upload IDs that were never attached to a submission and are past their 2 hours, along with their files, and the records of upload IDs that have been attached. Files attached to a submission are kept.

An upload ID whose upload URL has expired with no file stored stops counting towards the pending upload limit and the team's storage budget, so it can't hold up other uploads for the full 2 hours.

## Storage setup

Uploads are stored on the disk named by `QUICKFORMS_UPLOADS_DISK` (it falls back to `FILESYSTEM_DISK`), under `submissions/{form}/`. Direct uploads wait under `incoming/` until they are attached.

Production must use S3-compatible object storage. The `local` disk is for development only.

- **S3-compatible object storage**, such as Cloudflare R2, Amazon S3 or Laravel Cloud object storage: use an `s3` disk (the app includes `league/flysystem-aws-s3-v3`). Upload URLs point straight at the bucket. Add the [bucket CORS rule](#cors) for browser uploads.
- **Local**: the `local` disk has `serve` switched on, so the upload URL points at the app's own signed `/storage/...` route. The API is the same, which makes it handy for local development. That route refuses a body larger than the size given in step 1.

If the disk can't sign uploads, `POST /f/{form}/uploads` returns a `422` and multipart uploads keep working.

```bash title=".env"
QUICKFORMS_UPLOADS_DISK=s3
QUICKFORMS_MAX_UPLOAD_KB=10240
QUICKFORMS_FILE_LINK_DAYS=3
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=auto
AWS_BUCKET=your-bucket
AWS_ENDPOINT=https://YOUR-ACCOUNT-ID.r2.cloudflarestorage.com
```

The values above are illustrative, for an R2 bucket. Use your provider's endpoint, region and keys.

Source: https://docs.getquickforms.com/file-uploads/index.mdx
