SAVEFORM

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.

PlanLimits
FreeNo file uploads. A submission with a file attached is rejected with HTTP 403.
LiteUp to 3 files per submission · 4 MB per file · 1 GB total storage across all forms.
ProUp 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:

HTMLcontact.html
<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.

JavaScriptsubmit.js
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:

JSONsubmission.data (after upload)
{
  "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

JSONraw webhook body
{
  "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:

JSONdiscord template (excerpt)
{
  "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 a downloadUrl field — 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

StatusWhat it means
HTTP 403File upload attempted on a Free plan, or a custom file-rejection rule fired. Upgrade to Lite or higher.
HTTP 413A single file exceeded 4 MB, or the submission had more files than your plan allows.
HTTP 507Your storage cap is reached. Free up space by deleting old submissions, or upgrade.
HTTP 410A 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.

File uploads | SaveForm.io