Skip to content

File uploads

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.

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.

index.htmlhtml
<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:

  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.

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:

  • 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. 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.pdf

3. 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 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 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.

ApplyForm.jsxjsx
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." }.

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.

A file value with its download linkjson
{
  "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.
Email 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 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, 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 s3 disk (the app includes league/flysystem-aws-s3-v3). Upload URLs point straight at the bucket. Add the bucket CORS rule 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.

.envbash
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.

Type to search…

↑↓ navigate↵ selectEsc close