Skip to content
FreeToolNet
Go back

n8n form submitted by script: values are null, use field-0 keys

I built a hosted form in n8n and wanted to test it from a script instead of filling it in by hand each time. I posted the form fields to the form URL. The workflow ran, and every field value was null.

Two things were wrong with my assumptions:

  1. The form does not take the field labels as keys. It takes field-0, field-1, and so on.
  2. The response to the POST is not the result of the workflow. It is a pointer to a waiting page.

This post covers both, plus how the form is defined in the first place.

The form

The workflow starts with a Form Trigger node and ends with a Form node set to the “completion” operation. In between it validates the input, calls a few APIs and uploads a file to Google Drive. The completion page shows the Drive link.

I generate my workflows from a Python script, so the two form nodes are small helpers:

def form_trigger(name, pos, webhook_id, title, description, fields):
    return {"id": nid(), "name": name, "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2,
            "position": pos, "webhookId": webhook_id,
            "parameters": {"formTitle": title, "formDescription": description,
                           "formFields": {"values": fields}, "options": {}}}


def form_done(name, pos, title, message):
    return {"id": nid(), "name": name, "type": "n8n-nodes-base.form", "typeVersion": 2.5,
            "position": pos, "webhookId": nid(),
            "parameters": {"operation": "completion", "completionTitle": title,
                           "completionMessage": message, "options": {}}}

The form has two text fields. The first is a required URL. The second is optional:

{"fieldLabel": "Drive folder (optional)", "fieldType": "text", "requiredField": False,
 "placeholder": "Paste a Drive folder link or ID, or leave blank"},

I pass a short readable slug as the trigger’s webhookId instead of a random UUID. On my instance the active form is then served at https://n8n.example.com/form/<slug>, which is a URL I can type from memory.

Inside the workflow, fields are keyed by label

In the node right after the trigger, the submitted values are on $json under the field labels. This is part of my validation Code node:

const raw = String($json["Drive folder (optional)"] || "").trim();
let folder = "";
if (raw) {
  const m = raw.match(/[a-zA-Z0-9_-]{20,}/); // a Drive id, or the id inside a folder link
  folder = m ? m[0] : "";
  if (!folder)
    throw new Error(
      "That does not look like a Google Drive folder ID or link."
    );
}

That is what led me astray. The workflow reads $json['Drive folder (optional)'], so I assumed the HTTP request used the same key.

Gotcha 1: the HTTP keys are field-0, field-1

It does not. The hosted form page submits multipart/form-data, and the keys are positional: field-0 for the first field, field-1 for the second, and so on, in the order the fields are defined on the trigger. n8n maps them back to labels before the next node sees them.

When I posted with the labels as keys, the values were left null. Nothing told me the keys were wrong.

So a scripted submission needs to:

  • send multipart/form-data, not JSON;
  • use field-0, field-1, … as the part names;
  • keep the same order as the formFields list.

The keys are tied to position, not to meaning. If you reorder the fields on the trigger, check your test script again.

The fastest way to confirm the keys for your own form is to open it in a browser, submit once with the developer tools open on the Network tab, and read the request payload.

Gotcha 2: the POST does not return your result

With the keys fixed, the values arrived. The response still was not what I expected. I wanted the Drive link. I got this shape back:

{"formWaitingUrl":...}

And in the executions list, the run was not “success”. It was in status waiting.

This is how a form with a completion node behaves. The POST does not carry the final message. It returns a URL for a waiting page, which is where the browser goes next. The execution runs every node before the completion node, and is then left in waiting.

For testing this means:

  • A waiting execution is not a stuck execution. All the real work runs before the final completion node.
  • Do not judge the run by the POST response. It only tells you the submission was accepted.
  • Check the side effect or the execution data. I look at the last real node before the completion node. In my workflow that is the upload, which returns the new file’s ID.

The completion message can use expressions, which is how the link ends up on the final page:

form_done("Done", [2640, 0], "Done",
          "=Saved to Google Drive: {{ $('Request download').first().json.filename }}\n\n"
          "https://drive.google.com/file/d/{{ $('Upload bytes').first().json.id }}/view"),

Gotcha 3: validate the input yourself

The Form Trigger checks that required fields are filled. It does not check what is in them. A text field accepts anything.

My form is on a public hostname, and its values go into API calls. So the first node after the trigger is a Code node that throws on anything it does not like. This is the URL check:

if (!/^https?:\/\/[^\s'"\\]+$/.test(url))
  throw new Error("Enter a valid http(s) URL with no spaces or quotes.");

It accepts only http or https, and rejects whitespace, both kinds of quotes and backslashes. The folder field goes through the pattern shown earlier, so only something shaped like a Drive ID gets through.

A thrown error fails the execution. The person at the form sees that it failed, and because the workflow has an error workflow set, I also get an email about it.

Gotcha 4: long-running forms need a long proxy timeout

My form can keep a browser waiting for several minutes. n8n sits behind nginx, and a short proxy timeout would cut that off. The n8n site in my nginx config has these lines in its location / block:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;

The server block also sets client_max_body_size 50m;. On the n8n side, WEBHOOK_URL is set to the public https://n8n.example.com/ address and N8N_PROXY_HOPS=1, so that n8n builds public URLs with the right host and scheme when it runs behind the proxy.

Short version

  • Post multipart/form-data with keys field-0, field-1, … in field order. Labels as keys give you nulls and no error.
  • Inside the workflow, read values by label: $json['My label'].
  • The POST returns formWaitingUrl, and the execution shows waiting. The work before the completion node has still run.
  • Validate every field in a Code node. “Required” only means “not empty”.

The Form Trigger options are documented in the n8n Form Trigger docs.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-01 on Self-hosted n8n in Docker on a VPS behind nginx; hosted form built with the Form Trigger node and a Form completion node (n8n 2.45.5 (n8nio/n8n:2.45.5), Form Trigger node typeVersion 2.2, Form node typeVersion 2.5).



Previous Post
n8n 2.x "Unrecognized node type" for Execute Command: use a sidecar
Next Post
One n8n error workflow that emails me when any workflow fails