A file field 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 and 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. |
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 that expires.
Multipart
Give the form enctype="multipart/form-data" and post it to the submit endpoint as usual.
<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. HTML, SVG and script files are refused unless the field’s accept lists them.
Direct uploads
A direct upload takes three requests:
POST /f/{form}/uploadswith 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.PUTthe file to that URL with the headers it came with. The file goes straight to storage, not through the app.- 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.
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"}'{
"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:
fieldis a file field on the form.filenameis given (up to 255 characters) and its extension is in the field’sacceptlist, when it has one.sizeis a whole number of bytes, at least 1 and no more than the field’smax_size_kb(orQUICKFORMS_MAX_UPLOAD_KBwhen the field has no limit).content_typeis 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. 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.
curl -X PUT "UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @cv.pdf3. Submit the form
Send the submission as JSON, with the upl_... ID in place of the file.
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
sizefrom step 1 and within the field’s limit. - Its contents match the field’s
acceptlist, when it has one. - It isn’t HTML, SVG or a script, unless the field’s
acceptlists 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 PUTs to the upload URL can’t change an attached file.
The embed widget and hosted forms
The embed widget and 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.
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.
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.
{
"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 | submission.data.<field>.url in the event body. |
| The notification email links the file name to the download. | |
| API | Submission responses include a fresh link each time you fetch them. |
| Dashboard | The submission view links each file. |
| Destinations | A file field’s {{placeholder}} becomes its download link. |
| Exports | File columns hold the download link. |
Expiry
Links last QUICKFORMS_FILE_LINK_DAYS days (3 by default). Links in 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 a404. - On an
s3disk (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-Sandboxheader. Without it the response is a404. - With the sandbox key, the field is checked against the form’s draft, if it has one.
- A form that is switched off gets a
410.
CORS
The uploads endpoint uses the form’s allowed origins, 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 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
s3disk (the app includesleague/flysystem-aws-s3-v3). Upload URLs point straight at the bucket. Add the bucket CORS rule for browser uploads. - Local: the
localdisk hasserveswitched 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.
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.comThe values above are illustrative, for an R2 bucket. Use your provider’s endpoint, region and keys.