Features · Last updated May 16, 2026
File uploads
Accept file attachments through any form — résumés on a careers page, screenshots on a bug report, logos on a contractor onboarding flow. Files are stored on private blob storage, surfaced as download chips in the dashboard, and forwarded to your webhook receivers as signed URLs they can fetch without a SaveForm login.
Plan limits
File uploads are a paid-plan feature. Free submissions with a file attached are rejected before the upload reaches storage, so you never burn quota on something that won't go through.
| Plan | Limits |
|---|---|
| Free | No file uploads. A submission with a file attached is rejected with HTTP 403. |
| Lite | Up to 3 files per submission · 4 MB per file · 1 GB total storage across all forms. |
| Pro | Up to 10 files per submission · 4 MB per file · 10 GB total storage across all forms. |
HTML form
Add a regular <input type="file"> to your form and set the form's enctype to multipart/form-data. That's the only change — the action URL is the same SaveForm endpoint you already use:
<form action="https://saveform.io/api/submit/YOUR_FORM_ID" method="POST" enctype="multipart/form-data" > <input type="text" name="name" required /> <input type="email" name="email" required /> <textarea name="message" required></textarea> <!-- Optional attachment — leave empty to submit without a file --> <input type="file" name="attachment" /> <button type="submit">Send</button> </form>
The field name you pick (attachment in the example) becomes the key in the submission's JSON data, the column in CSV exports, and the placeholder name in webhook templates ({{attachment.url}}).
JavaScript / fetch
From any JS frontend, build a FormData object and pass it to fetch. Crucially, do not set the Content-Type header manually — the browser must set it so the multipart boundary is included. Setting it explicitly to multipart/form-data (without a boundary) breaks parsing on the server.
async function submitContact(form) {
const body = new FormData(form); // includes <input type="file"> automatically
const res = await fetch(
"https://saveform.io/api/submit/YOUR_FORM_ID",
{ method: "POST", body } // no Content-Type header — let the browser handle it
);
if (!res.ok) {
const { error } = await res.json().catch(() => ({}));
throw new Error(error || `HTTP ${res.status}`);
}
return res.json();
}The submission JSON returned on success looks the same as a textonly form, except every file field is replaced with a small reference object that points to the stored file:
{
"name": "Jane",
"email": "jane@example.com",
"attachment": {
"__file": true,
"fileId": "8b5c4b0e-…",
"filename": "resume.pdf",
"mimeType": "application/pdf",
"size": 184213
}
}In the dashboard
Submissions with files show a paperclip icon and a file count in the preview column. Open the submission to see each file as a download chip — click to download. The download is gated behind your SaveForm session, so files never leak to anyone who shouldn't see them.
- Files are streamed straight from blob storage via a short-lived presigned URL — SaveForm never proxies the bytes, so downloads stay fast even for large attachments.
- Deleting a submission deletes its files too. There is no soft-delete window for files; deletion is immediate and final.
- The Storage Used tile on the dashboard reflects your live storage usage across all forms.
In webhook payloads
When a submission with files fires a webhook, every file reference in the payload is enriched with an absolute, signed download URL the receiver can fetch without a SaveForm login. The URL is signed with HMAC-SHA256 over the file ID and expiry; tampering with the file ID or expiry invalidates the signature.
Raw mode
{
"submissionId": "…",
"formName": "Contact",
"receivedAt": "2026-05-23T01:03:57.285Z",
"isSpam": false,
"data": {
"name": "Jane",
"email": "jane@example.com",
"attachment": {
"__file": true,
"fileId": "8b5c4b0e-…",
"filename": "resume.pdf",
"mimeType": "application/pdf",
"size": 184213,
"url": "https://saveform.io/api/files/8b5c…?exp=…&token=…",
"downloadUrl": "https://saveform.io/api/files/8b5c…?exp=…&token=…",
"expiresAt": 1796000000000
}
}
}Template mode
In template mode (Discord, Slack, custom JSON), reach into the file object with dot-paths. The renderer also accepts indexed access for multi-file fields and substitutes file references inside larger strings:
{
"embeds": [
{
"title": "New submission",
"description": "{{_allFieldsText}}",
"fields": [
{
"name": "Attachment",
"value": "[{{attachment.filename}}]({{attachment.url}})"
}
],
"footer": {
"text": "Submission {{_submissionId}} · {{_timestamp}}"
}
}
]
}Signed URLs are valid for about a year so retried and archived deliveries keep working. If a URL ever leaks, rotating the server- side signing secret invalidates every previously issued URL — that is the intended kill-switch. Receivers see a 410 Gone after the expiry; the SaveForm dashboard can always re-fire the webhook with fresh URLs.
In CSV / JSON exports
Both export formats are file-aware so anything you download can be used to fully reconstruct a submission later.
- CSV. File cells are written as absolute SaveForm download URLs. Multi-file cells join URLs with
|so they stay one row per submission. - JSON. File references keep their full metadata (
fileId,filename,mimeType,size) and gain adownloadUrlfield — so a JSON export is a self-contained archive you can hand to a teammate.
In notification emails
The submission notification you receive (and any auto-reply you have configured) handle file references too. Owner notifications include a clickable download line per file; auto-reply emails just mention the filename, no link.
Errors you might see
| Status | What it means |
|---|---|
| HTTP 403 | File upload attempted on a Free plan, or a custom file-rejection rule fired. Upgrade to Lite or higher. |
| HTTP 413 | A single file exceeded 4 MB, or the submission had more files than your plan allows. |
| HTTP 507 | Your storage cap is reached. Free up space by deleting old submissions, or upgrade. |
| HTTP 410 | A webhook receiver tried to download a file with an expired signed URL. Re-fire the webhook from the dashboard to mint a fresh URL. |
Security
- Files live in private blob storage; the raw blob URL is never exposed.
- Dashboard downloads go through
/api/files/[fileId], which checks your session and the file's ownership before redirecting to a short-lived presigned URL (~5 minutes). - Webhook URLs use an HMAC token tied to the file ID and expiry. Tampering with either invalidates the signature.
- Filenames are sanitised server-side. The original filename is kept for display only — never used as part of a filesystem or URL path.
- Submission deletion (manual or via retention) deletes the blob first, then the database row. No orphan files.
Retention & deletion
Files follow the same retention rules as the submission they belong to. When the submission is auto-deleted on Free or Lite, its files are deleted in the same pass. See Data retention for the per-plan windows. Manually deleting a submission from the dashboard removes its files immediately.