At a glance

What you will take away

  1. A plain HTML form can put files in your own Google Drive, and the visitor never signs in to anything.

  2. The upload is two phases: the answers go first, and the bytes follow to a per-file address the reply hands back.

  3. The file is only useful if the row that explains it points at it, which is why the Drive link lands in your Google record.

What actually happens when an HTML form uploads to Google Drive

A file input on your own page can deliver into your own Google Drive folder without the visitor holding a Google account, but not by posting the file straight to Google. Drive will not accept an unauthenticated browser upload, so the form posts its answers to a service you have authorized against your Google account, and that service hands the browser a one-file, one-use address to send the bytes to.

That is the whole mechanism, and it is worth stating up front because almost every answer you will find for this describes something else. The results for this search are Apps Script recipes, a Google Forms upload question, and Workspace add-ons that wrap the same two. All of them work. All of them either require the person uploading to sign in to a Google account, or require you to abandon the form you built and use Google Forms instead.

This guide covers the other shape: your markup, your fields, your page, and a file that lands in a Drive folder you own.

Why the page-one answers all need a Google account

Google Forms has a file upload question, and it is genuinely good. It also displays a notice the moment you add one: respondents will be required to sign in to Google when file upload fields are added to a form. There is no setting to turn that off. Making the form public does not remove it, turning off email collection does not remove it, and neither does relaxing the one-response limit.

The reason is not arbitrary. An upload through Google Forms is written into the form owner's Drive, and Google attributes that write to an identified account so it can attach ownership, scan the file, and refuse anonymous writes into private storage. If everyone filling in your form already works in your organization, this costs you nothing. If you are collecting from customers, applicants, parents, contractors or the general public, it is the whole problem: a meaningful share of the people you are asking will not have a Google account, will not want to create one, and will close the tab.

The Apps Script recipes get around it differently. You deploy a web app, set it to run as you and to be accessible to anyone, and post the file to it as a base64 string. That works, and it is free. It also means you are now maintaining a deployed script, a quota you do not control, and a code path that converts every file into text and back, which is where the size ceilings people complain about come from.

The form

Nothing about the markup is unusual. A file input is a file input, and the fields around it are ordinary fields. What matters is that the file input has a name, because that name is how the upload is matched to the field it belongs to on the way through.

<form action="YOUR_SUBMIT_URL" method="POST"
      data-webtzm-form="YOUR_WORKFLOW_ID">
  <input name="name" placeholder="Maya Lee" required>
  <input name="email" type="email" placeholder="maya@example.com" required>
  <input name="cv" type="file" accept=".pdf,.doc,.docx" required>
  <textarea name="message"></textarea>
  <p data-wtzm-send-status></p>
  <button>Send</button>
</form>
<script src="YOUR_SCRIPT_URL"></script>

The three placeholder values are filled in for you when you create the workflow; the console hands you this snippet with the real submit address, workflow id and script URL already in it. The script is what runs the two-phase upload described below, reports progress into the data-wtzm-send-status element, and resets the form afterwards. If that is all you need, you are finished here and the rest of this section is background.

The two-phase upload, if you would rather write it yourself

You do not have to use the script. The exchange underneath it is a plain pair of requests, and knowing it is the difference between a form you can debug and a form you can only re-paste.

Phase one sends the answers and a description of the files

The first request posts the text answers together with a manifest: for each selected file, which field it came from, what it is called, what type it claims to be, and how many bytes it is. The bytes themselves are not in this request.

const fields = form.elements;
const file = fields.namedItem("cv").files[0];

const response = await fetch(FORM_ACTION_URL, {
  method: "POST",
  headers: { "Content-Type": "text/plain;charset=UTF-8" },
  body: JSON.stringify({
    formId: WORKFLOW_ID,
    idempotencyKey: crypto.randomUUID(),
    payload: {
      name: fields.namedItem("name").value,
      email: fields.namedItem("email").value,
      message: fields.namedItem("message").value
    },
    files: [{
      fieldKey: "cv",
      fileName: file.name,
      mimeType: file.type,
      byteSize: file.size
    }]
  })
});
const result = await response.json();

The content type is deliberate. Sending this as application/json makes the browser issue a preflight request before every submission; text/plain;charset=UTF-8 does not, and the body is parsed as JSON either way. It is one round trip saved on the request that matters most.

The manifest is also where a bad upload is stopped, before a single byte has moved. The declared extension and type are checked against what the field allows, the count against the number of files that field accepts, and the size against the ceiling. A submission that fails those checks is refused here, which means a visitor who picked the wrong file is told so immediately rather than after a slow upload.

Phase two sends the bytes

A successful reply contains an uploads array with one entry per file, each carrying the field, the slot, and a URL:

{
  "status": "uploading",
  "uploads": [
    { "uploadId": "…", "fieldKey": "cv", "slot": 1, "url": "https://…/upload/…" }
  ]
}

Each URL is single-purpose. It is bound to one submission, one field, one slot, one declared size and one Drive file that has already been reserved in your folder, and it expires shortly after it is issued. Send the file to it with a PUT:

const selected = { cv: file };

for (const upload of result.uploads ?? []) {
  const bytes = selected[upload.fieldKey];
  await fetch(upload.url, {
    method: "PUT",
    headers: { "Content-Type": bytes.type },
    body: bytes
  });
}

That is the part worth understanding, because it is what makes the whole arrangement safe. The visitor is never given credentials, never given a folder, and never given anything reusable. They are given permission to write exactly one file that you have already agreed to accept. There is nothing in that URL that can be pointed at a second file or a different folder, and nothing that still works tomorrow.

It also explains the failure behaviour. The submission is accepted in phase one. If the bytes never arrive, you still have the enquiry, the name, the email and the message, marked as having an attachment that did not finish, rather than losing the whole thing because someone's connection dropped at the wrong moment.

Where the files land, and how you find them again

Uploads go into a folder in your own Drive named after the workflow, inside a single Webtzm folder at the top level of your Drive. You can point a workflow at a folder you already have instead, and if you rename the workflow the folder follows.

They are your files under your account, subject to your own retention and sharing rules. The authorization Webtzm holds against your Google account is the narrow drive.file scope, which is limited to files it creates itself and cannot see anything else you keep in Drive.

The part that makes a file useful, though, is the record beside it. A file on its own is a mystery: a PDF called scan.pdf tells you nothing about who sent it or what it is for. So when the workflow delivers to a Google Sheet, each file slot gets its own column, named for the field it came from, holding the link to the file in Drive. The row carries the name, the email, the message and the timestamp; the column carries the way to open the attachment they belong to. You review the row and click through, rather than opening a folder of files and guessing.

What is refused before it reaches Drive

A public upload field is an invitation, so the defaults are narrow and you widen them deliberately.

  • Each field declares which extensions it accepts, and the claimed content type has to be consistent with the extension. A file renamed to get past the picker does not get past this.

  • Executable and script file types are refused unless the field has been explicitly set to allow them.

  • There are ceilings on the size of a single file, on how many files one field takes, and on the total for one submission. A field can be tightened below those but not above.

  • A required file field with nothing attached fails the submission. An optional one can be set to let the rest of the submission through when the attachment does not arrive.

File upload allowances are part of the plan a workspace is on; the pricing page carries the current figures.

When Apps Script is still the right answer

Stick with Google Forms and its upload question if everyone who fills in the form is already inside your Google Workspace domain. The sign-in requirement is not a barrier for them, you get Drive uploads for nothing, and adding a third party to that is work with no return.

Stick with an Apps Script web app if the upload is one step in a larger script you already run and maintain, or if what you actually need is not an upload at all but a transformation that has to run inside your Google account. A deployed script can do things no external service can, because it is running as you.

The case for a form connection instead is narrower than the vendors selling it usually admit: you want the form on your own site, in your own design, and you are collecting from people who do not have a Google account and should not have to get one. That is a common situation. It is just not every situation.

Try the pattern

Point one upload form at your own Drive folder.

Start with a form that already receives attachments by email, and see what changes when the file arrives filed against the answers that explain it.

Build your first Webtzm delivery  →

Frequently asked questions

Do people uploading a file need a Google account?

No. The file is written into your Drive by a service you authorized against your own Google account, not by the visitor, so there is nothing for them to sign in to. This is the main difference from a Google Forms upload question, which does require the respondent to sign in and offers no setting to turn that off.

Where do the uploaded files actually go?

Into a folder in your own Google Drive, named after the workflow, inside a single Webtzm folder at the top level of your Drive. You can point a workflow at a folder you already use instead. The files live under your account and your own retention rules.

How do I know which submission a file belongs to?

Each file slot gets its own column in your Google Sheet, named for the field it came from and holding the link to the file in Drive. The row carries the name, the message and the timestamp, so the record and the attachment point at each other rather than being two separate piles.

Can a visitor reach anything else in my Drive?

No. The address the browser is given covers exactly one file on one submission, at the size it declared, and it expires shortly after it is issued. There is no folder listing and no path to walk. The authorization itself is limited to files it creates, so the rest of your Drive is not visible to it either.

What happens if the upload fails halfway?

The submission is accepted before the bytes move, so you keep the enquiry and it is flagged as having an attachment that did not finish. You do not lose the whole message because someone's connection dropped.

Is this better than an Apps Script web app?

Not always. A deployed script runs as you and can do things no external service can, and it is free. It is the better answer when the upload is one step in a larger script you already maintain. This route is the better answer when you want the form on your own site and your visitors do not have Google accounts.