# Form2Workflow documentation (complete) > Every page of the form2wf docs in one file. Index: https://form2wf.com/llms.txt --- # Form2Workflow documentation Form2Workflow (form2wf) turns n8n workflows into the backend for any website. Build a form in the dashboard, point it at an n8n webhook, and drop one script tag on your site — no server code. ## What form2wf does form2wf sits between a static website and your n8n instance. It renders forms and data pages on your site, sends what visitors submit to your n8n workflows, and brings data back from n8n to fill forms and tables. Your workflows are the backend: send email, write to a database or CRM, call an AI model, anything n8n can do. - - **Forms**21 field types, charts, nested objects, repeatable groups, file uploads, multi-column layouts. - **Data pages**Load data from n8n into tables and read-only fields, with row actions and buttons. - **Verified & protected forms**Sign in with Microsoft (no accounts to manage), or your own form users and roles. - **AI agents (MCP)**Let Claude or any MCP client build and manage forms with a scoped API key. ## How the pieces fit request flow: ``` Visitor's browser form2wf Your n8n ───────────────── ─────── ──────── f2wf.js loads form ───────▶ GET form config (+ onload webhook) ─────────▶ Webhook ▶ fetch data ▶ Respond form renders ◀─────── config + data ◀───────── visitor submits ───────▶ POST /submit ─────────▶ Webhook ▶ email / DB / AI ... "Thank you!" ◀─────── success ◀───────── 2xx ``` form2wf stores the form definitions, enforces quotas, rate limits and login rules, logs every submission, and keeps your webhook URLs and header secrets off the page — the browser never sees them. ## Start here - [**Quick start**A working contact form wired to n8n in about ten minutes.](https://form2wf.com/docs/quickstart) [**Field reference**Every field type with a copy-paste sample.](https://form2wf.com/docs/fields) [**Webhooks & payloads**Exactly what n8n receives and what it should send back.](https://form2wf.com/docs/webhooks) [**MCP server & API keys**Connect Claude Code, Claude Desktop, Cursor or an n8n AI agent.](https://form2wf.com/docs/mcp) [**n8n recipes**Ready-made workflow patterns: email, sheets, CRUD, uploads.](https://form2wf.com/docs/n8n-recipes) [**Best practices**Security, reliability and performance guidance.](https://form2wf.com/docs/best-practices) ## Key terms | Term | Meaning | | --- | --- | | Organization | Your workspace. Forms, themes, form users and API keys all belong to one organization. Its `slug` appears in embed snippets and hosted URLs. | | Form | A page definition: settings (name, slug, webhooks) plus a `config` JSON describing its fields. Also called an endpoint in the API. | | Submit webhook | The n8n URL that receives submissions (`n8n_webhook_url`). No submit webhook means no submit button — a read-only data page. | | Onload webhook | An n8n URL called when the form loads. Its response pre-fills fields and feeds tables. | | Form user | Someone who signs in to use a login-protected form. Separate from dashboard team members. | | Team member | A dashboard user with a role: owner, admin, creator or user. | | API key | An `f2w_` key that lets an AI agent act in your organization through the MCP server, at a chosen access level. | --- Source: https://form2wf.com/docs — Form2Workflow documentation (Getting started) --- # Quick start Build a contact form, send its submissions to n8n, and put it on a web page. You need a form2wf account and an n8n instance reachable from the internet. 1. ### Create the n8n workflow In n8n, create a workflow that starts with a **Webhook** node: - **HTTP Method:** `POST` - **Path:** `contact` (anything unique) - **Respond:** _Immediately_ is fine for a plain form Add what should happen next — for example a **Send Email** node whose body uses `{{ $json.body.data.message }}`. Submitted values are always under `body.data`. **Activate** the workflow and copy the _Production URL_ (it contains `/webhook/`, not `/webhook-test/`). 2. ### Create the form In the dashboard, open **Forms** and click **Create Form**. Name it _Contact_ and add three fields: _Name_ (text, required), _Email_ (email, required) and _Message_ (textarea). Open **Form Settings**, paste the production URL under **Submit Webhook**, and save. The same form as JSON (what the [MCP server](https://form2wf.com/docs/mcp) and API use): config: ``` { "showTitle": true, "fields": [ { "id": "name", "type": "text", "name": "name", "label": "Name", "required": true }, { "id": "email", "type": "email", "name": "email", "label": "Email", "required": true }, { "id": "message", "type": "textarea", "name": "message", "label": "Message", "required": true } ] } ``` 3. ### Test it on the hosted page Every form gets a standalone page at `https://form2wf.com/forms//` (the **Embed** button shows the exact link). Submit a test message. In n8n, the execution shows a body like: what n8n receives: ``` { "endpointId": "3f1c…", "data": { "name": "Ada", "email": "ada@example.com", "message": "Hello!" }, "metadata": { "timestamp": "2026-09-29T14:03:11.000Z", "origin": "https://your-site.com", "userAgent": "…", "ip": "…" }, "userId": null, "user": null, "endUserId": null, "endUser": null } ``` 4. ### Embed it on your site Click **Embed** on the form and paste the snippet where the form should appear: html: ```html
``` That's it: the form renders with your theme, validates required fields, and posts to n8n through form2wf. 5. ### Check deliveries Each submission appears as an execution in n8n. form2wf also logs every webhook call with its outcome and n8n's response or error — ask an agent connected through [MCP](https://form2wf.com/docs/mcp) to “list failed submissions for the contact form” (the `list_submissions` tool). **Prefer to build with an AI agent?** Create an API key under **API Keys** in the dashboard, connect Claude Code with one command, and ask it to “create a contact form that posts to my n8n webhook”. See [MCP server & API keys](https://form2wf.com/docs/mcp). ## Next steps - [Field reference](https://form2wf.com/docs/fields) — dropdowns, file uploads, repeatable groups and more. - [Data pages](https://form2wf.com/docs/data-pages) — show n8n data in tables with row actions. - [Webhooks & payloads](https://form2wf.com/docs/webhooks) — secure your webhook and handle every payload shape. --- Source: https://form2wf.com/docs/quickstart — Form2Workflow documentation (Getting started) --- # How it works The request lifecycle, the objects you work with, and who can do what. ## Request lifecycle ### 1\. Loading a form `f2wf.load(org, form)` requests the form's public config from form2wf. The response contains the fields and layout but never your webhook URLs or header secrets. If the form requires login, the widget checks the visitor's session first (see [Who can use a form](https://form2wf.com/docs/access)). If the form has an onload webhook, the widget then calls it through form2wf and uses the response to fill fields and tables. ### 2\. Submitting The widget posts the values to `/api/v1/submit/`. form2wf checks the form is active, the visitor is allowed, and your monthly quota has room, then forwards the data to your submit webhook with your configured headers. n8n has 30 seconds to answer. Any 2xx reply is a success; the visitor sees your success message. ### 3\. Buttons and row actions Buttons and table row actions call webhooks you configured on the field. The browser only sends which button was clicked — the URL, method and headers always come from the stored form, so a visitor can't redirect a call elsewhere. ## Objects | Object | Belongs to | Key facts | | --- | --- | --- | | Organization | — | Has a plan (quotas), team members, a slug used in public URLs. | | Form | Organization | UUID `id` (used by the submit API and SDK) and `slug` (used in embed snippets and hosted URLs). Settings + `config` JSON. | | Folder | Organization | Dashboard organization only. Deleting a folder keeps its forms. | | Submission | Form | Logged for every attempt with status and n8n response. | | Theme | Organization | Colors and spacing, applied through `config.theme_id`. | | Form user / role | Organization | Who may use Protected forms. | | API key | Organization | Access for AI agents through MCP; acts as the member who created it. | ## Team roles | Capability | User | Creator | Admin | Owner | | --- | --- | --- | --- | --- | | View forms, submissions, usage | Yes | Yes | Yes | Yes | | Create and edit forms and folders | — | Yes | Yes | Yes | | Delete forms, form access, themes, roles, form users | — | — | Yes | Yes | | Invite and manage team, API keys, billing checkout | — | — | Yes | Yes | | Change plan, cancel, transfer ownership | — | — | — | Yes | API keys map onto these roles: a viewer key acts as a user, editor as a creator, admin as an admin — never higher than the role of the member who created it. ## IDs vs slugs Embed snippets, hosted pages, navigation targets and `targetForm` use the form **slug**. The submit/fetch API, the SDK and MCP tools use the form **UUID**. Changing a slug breaks existing embeds and links to it; the UUID never changes. --- Source: https://form2wf.com/docs/concepts — Form2Workflow documentation (Getting started) --- # What you can build Ready-to-copy blueprints: a client portal with Microsoft sign-in, an approval queue, a quote that ends at Stripe checkout, an AI answer page and an order-status lookup. Each blueprint lists the forms (paste the JSON with `update_form`, or build them in the editor) and what each n8n workflow does. Replace `https://n8n.example.com` with your n8n and keep every workflow's Webhook node set to respond with a _Respond to Webhook_ node. Building with an AI agent? Point it at this page: “Build the client portal blueprint from form2wf's use-cases page in my organization.” The [MCP server](https://form2wf.com/docs/mcp) can create the forms; you wire the n8n side. ## Client portal with Microsoft sign-in Clients sign in with their work Microsoft account, see their projects and open requests, and file new ones. No accounts to create: access is limited to their company's email domain. **Access:** both forms **Verified**, email domain `clientco.com` (see [Verified](https://form2wf.com/docs/access#verified)). **Theme:** put both in one folder with a folder theme. form portal-home (onload: portal-home webhook): ``` { "showTitle": false, "fields": [ { "id": "menu", "type": "nav", "navItems": [ { "label": "Overview", "form": "portal-home" }, { "label": "New request", "form": "portal-request" } ] }, { "id": "hello", "type": "markdown", "markdown": "## Welcome back, {{data.name}}\n\n{{data.notice}}" }, { "id": "projects", "type": "table", "label": "Your projects", "dataSource": "projects", "tableSearch": true, "pageSize": 10, "columns": [ { "field": "name", "label": "Project" }, { "field": "stage", "label": "Stage" }, { "field": "due", "label": "Due" } ] } ] } ``` form portal-request (submit: portal-request webhook): ``` { "showTitle": false, "fields": [ { "id": "menu", "type": "nav", "navItems": [ { "label": "Overview", "form": "portal-home" }, { "label": "New request", "form": "portal-request" } ] }, { "id": "subject", "type": "text", "name": "subject", "label": "Subject", "required": true }, { "id": "details", "type": "textarea", "name": "details", "label": "What do you need?", "required": true }, { "id": "file", "type": "file", "name": "attachment", "label": "Attachment" } ] } ``` - **portal-home workflow:** read `body.endUser.email` (verified by Microsoft), look up that client's projects in your project tool, reply `{ "data": { "name": …, "notice": …, "projects": [ … ] } }`. - **portal-request workflow:** create the ticket tagged with `body.endUser.email`, then reply `{ "message": "Thanks — we'll reply within a day.", "navigate": { "form": "portal-home" } }` to land back on the overview. ## Approval queue Managers see pending expense claims and approve or reject each with one click. The table redraws itself after every action. **Access:** Verified with your own domain, or **Protected** with a _Managers_ role. form approvals (onload: list-pending webhook): ``` { "fields": [ { "id": "queue", "type": "table", "label": "Waiting for approval", "dataSource": "claims", "tableSearch": true, "pageSize": 25, "columns": [ { "field": "employee", "label": "Employee" }, { "field": "amount", "label": "Amount" }, { "field": "reason", "label": "Reason" }, { "field": "submitted", "label": "Submitted" } ], "rowActions": [ { "id": "approve", "label": "Approve", "type": "webhook", "style": "primary", "webhookUrl": "https://n8n.example.com/webhook/claims/approve?id={{row.id}}" }, { "id": "reject", "label": "Reject", "type": "webhook", "style": "danger", "confirmMessage": "Reject this claim?", "webhookUrl": "https://n8n.example.com/webhook/claims/reject?id={{row.id}}" } ] } ] } ``` - **list-pending:** query claims with status _pending_; reply `{ "data": { "claims": [ … ] } }`. - **approve / reject:** check the `X-F2WF-User-Email` header is a manager, update the claim by the `id` query value, notify the employee, reply `{ "message": "Approved" }`. The table reloads its rows automatically. ## Quote that ends at Stripe checkout A visitor picks options, sees the price, and pays — your workflow prices the order and creates the Stripe Checkout session, so prices can't be tampered with in the browser. form quote (submit: create-checkout webhook): ``` { "submitText": "Continue to payment", "fields": [ { "id": "plan", "type": "radio", "name": "plan", "label": "Package", "options": ["Starter", "Growth", "Scale"], "required": true, "defaultValue": "{{params.plan}}" }, { "id": "seats", "type": "number", "name": "seats", "label": "Seats", "defaultValue": "5", "required": true }, { "id": "email", "type": "email", "name": "email", "label": "Billing email", "required": true }, { "id": "terms", "type": "markdown", "markdown": "By continuing you agree to the [terms](https://example.com/terms). You can cancel any time." } ] } ``` - **create-checkout:** look up the package price on the server, create a Stripe Checkout Session (n8n's Stripe node or an HTTP Request to `/v1/checkout/sessions`) with `success_url` pointing at a thank-you page, then reply `{ "redirectUrl": "" }`. The widget sends the visitor straight to Stripe. - **stripe-webhook** (a second workflow, called by Stripe): on `checkout.session.completed`, provision the order and email the receipt. Link to it with a preselected package: `https://your-site.com/buy?plan=Growth`. ## AI answer page A visitor pastes a contract (or a job description, a support question…) and gets a formatted AI answer on the next page. Sign-in keeps strangers from spending your model budget. **Access:** Verified (any Microsoft account), or Public with a strict rate limit for a lead magnet that collects an email first. form review (submit: run-review webhook): ``` { "submitText": "Review it", "fields": [ { "id": "text", "type": "textarea", "name": "contract", "label": "Paste the contract", "required": true }, { "id": "focus", "type": "select", "name": "focus", "label": "Focus on", "options": ["Risks", "Payment terms", "Termination"] } ] } ``` form review-result (onload: get-review webhook): ``` { "fields": [ { "id": "answer", "type": "markdown", "dataSource": "review", "maxHeight": 600 }, { "id": "again", "type": "link", "label": "Review another", "linkAction": "navigate", "linkTargetForm": "review" } ] } ``` - **run-review:** send `body.data.contract` to your model (n8n's AI Agent or OpenAI/Anthropic node) asking for a markdown answer, store it with an id, reply `{ "navigate": { "form": "review-result", "params": { "id": "" } } }`. - **get-review:** load the stored answer by `body.params.id` (check it belongs to `body.endUser.id` on a Verified form) and reply `{ "data": { "review": "### Summary\n…" } }`. Model output is shown as markdown: headings, lists and tables render, while any HTML or script in the answer is shown as plain text. ## Order status lookup Customers check an order without logging in: they enter the order number and the email it was placed with. form track (submit: find-order webhook): ``` { "submitText": "Find my order", "fields": [ { "id": "order", "type": "text", "name": "order", "label": "Order number", "required": true, "defaultValue": "{{params.order}}" }, { "id": "email", "type": "email", "name": "email", "label": "Email used for the order", "required": true } ] } ``` form track-result (onload: order-details webhook): ``` { "fields": [ { "id": "status", "type": "markdown", "dataSource": "summary" }, { "id": "items", "type": "table", "label": "Items", "dataSource": "items", "columns": [ { "field": "name", "label": "Item" }, { "field": "qty", "label": "Qty" } ] } ] } ``` - **find-order:** look the order up in Shopify/WooCommerce; if the email matches, store a short-lived lookup token and reply `{ "navigate": { "form": "track-result", "params": { "t": "" } } }`, otherwise `{ "message": "We couldn't find that order." , "refresh": true }`. - **order-details:** resolve `body.params.t` to the order and reply with a markdown `summary` and the `items`. Never pass the raw order number alone to the result page — anyone could guess the next one. Use a random token (as above) or make the result page Verified. ## More ideas - **Bookkeeper intake:** a Verified upload form per client; the onload webhook shows a "still missing" checklist as markdown task list (`- [x] T4`, `- [ ] RRSP receipt`). - **Event check-in:** a searchable attendee table with a _Check in_ row action. - **Tenant maintenance:** Verified request form with photo upload, plus a table of open tickets for that tenant. - **Ops console:** buttons for "Re-run sync", "Resend invoice" that wrap workflows a teammate shouldn't touch directly. --- Source: https://form2wf.com/docs/use-cases — Form2Workflow documentation (Getting started) --- # Form settings & layout Every form is a set of settings plus a `config` JSON object. This page covers the settings and the top-level config keys that control layout. ## Form settings Set these in the dashboard's **Form Settings** dialog, or with the `create_form` / `update_form` [MCP tools](https://form2wf.com/docs/mcp-tools). | Setting | Description | | --- | --- | | `name` | Display name, shown as the form title. | | `slug` | URL-safe identifier, unique within your organization. Generated from the name if you don't set one. Used in embed snippets, hosted URLs and navigation. | | `n8n_webhook_url` | Submit webhook. The submit button only appears when this is set. | | `webhook_headers` | Headers sent with every submission, e.g. an auth header your n8n Webhook node checks. Stored server-side, never exposed to the browser. | | `onload_webhook_url`, `onload_webhook_method`, `onload_webhook_headers` | Webhook called when the form loads. See [Data pages](https://form2wf.com/docs/data-pages). | | `is_active` | Inactive forms don't load, don't show on hosted pages and reject submissions. Use this to take a form offline without deleting it. | | `auth_required` | Protected: visitors must sign in as form users. Verified forms use `verified_required` instead. See [Who can use a form](https://form2wf.com/docs/access). | | `folder_id` | Dashboard folder. Organizational only. | ## Top-level config | Key | Type | Default | Effect | | --- | --- | --- | --- | | `showTitle` | boolean | `true` | `false` hides the form name heading — useful when the page already has its own heading. | | `columns` | 1–4 | `1` | Number of grid columns. Collapses to one column on screens narrower than 768px. | | `theme_id` | UUID | none | Theme to apply. See [Themes](https://form2wf.com/docs/themes). | | `buttonColor` | hex | `#2551ed` | Legacy primary color, used only when no theme is set. | | `fields` | array | `[]` | The fields, in display order. See the [Field reference](https://form2wf.com/docs/fields). | ## Multi-column layouts With `columns` set, fields flow left-to-right into the grid. Use `linebreak` to end a row early, and `subtitle` / `divider` for sections — layout and structural fields (`subtitle`, `divider`, `linebreak`, `table`, `object`, `array`) always span the full width. Each other field takes one column unless you give it a width: `"colSpan": 2` (any number up to `columns`) or `"colSpan": "full"`. In the dashboard, open a field and choose **Width**. Widths larger than the form's column count are treated as full width, and on screens narrower than 768px every field is full width. Fields inside `object` and `array` groups follow the same grid. To control where a field goes, set `"newRow": true` to start it on a new row, or `"colStart": 3` to put it in a specific column (in the dashboard, **Starts**). Columns before it on that row stay empty, so `"colStart": 2` on a two-column form puts a field on the right with nothing beside it. If the row has already passed that column, the field moves to the next row. These replace most uses of `linebreak`. config — a field alone on the right: ``` { "columns": 2, "fields": [ { "id": "first", "type": "text", "name": "first_name", "label": "First name" }, { "id": "last", "type": "text", "name": "last_name", "label": "Last name" }, { "id": "notes", "type": "textarea", "name": "notes", "label": "Notes", "colSpan": "full" }, { "id": "code", "type": "text", "name": "promo", "label": "Promo code", "colStart": 2 } ] } ``` config — two-column registration form: ``` { "showTitle": false, "columns": 2, "fields": [ { "id": "s1", "type": "subtitle", "label": "Your details" }, { "id": "first", "type": "text", "name": "first_name", "label": "First name", "required": true }, { "id": "last", "type": "text", "name": "last_name", "label": "Last name", "required": true }, { "id": "email", "type": "email", "name": "email", "label": "Email", "required": true, "colSpan": "full" }, { "id": "d1", "type": "divider" }, { "id": "s2", "type": "subtitle", "label": "Event" }, { "id": "track", "type": "select", "name": "track", "label": "Track", "options": ["Workshop", "Talks", "Both"] }, { "id": "diet", "type": "text", "name": "dietary", "label": "Dietary needs", "placeholder": "Optional" } ] } ``` ## Editing a form's config The dashboard editor saves the whole `config` at once. Through the API or MCP, `config` is also **replaced as a whole** — read the form first, change what you need, and send back the complete object. Sending only `{"fields": [...]}` drops `showTitle`, `columns` and `theme_id`. Changing a `slug` breaks every embed snippet, hosted link and `targetForm` that uses the old slug. --- Source: https://form2wf.com/docs/forms — Form2Workflow documentation (Building forms) --- # Field reference Every field type form2wf renders, with a sample and the data it submits. ## Common properties | Property | Description | | --- | --- | | `id` | Unique id within the form. Required for buttons and row actions to work — always set one (the dashboard generates `field_…` ids). | | `type` | One of the types below. | | `name` | Key in the submitted `data`. | | `label` | Label shown above the input. | | `placeholder` | Placeholder text. For `subtitle` it is the heading text; for `checkbox`, the text beside the box. | | `required` | Blocks submission until filled (browser-side check). | | `colSpan` | How many grid columns the field takes, from `1` (default) up to the form's `columns`, or `"full"` for the whole row. See [multi-column layouts](https://form2wf.com/docs/forms#columns). | | `newRow` | `true` starts the field on a new row. | | `colStart` | Column the field starts in (`1` = new row). If earlier fields on the row already use that column, the field moves to the next row. | | `defaultValue` | Initial value. Supports [templates](https://form2wf.com/docs/templates) such as `{{params.plan}}`. | ## Text inputs: text, email, number, tel, date, url, password Rendered as ``. Any HTML input type works — the type is passed through — and browsers apply their native validation for `email`, `url` and `number`. All values are submitted as strings. ``` { "id": "phone", "type": "tel", "name": "phone", "label": "Phone", "placeholder": "+1 555 0100" } { "id": "start", "type": "date", "name": "start_date", "label": "Start date", "required": true } { "id": "qty", "type": "number", "name": "quantity", "label": "Quantity", "defaultValue": "1" } ``` submits: ``` { "phone": "+1 555 0100", "start_date": "2026-10-01", "quantity": "1" } ``` ## textarea ``` { "id": "msg", "type": "textarea", "name": "message", "label": "Message", "required": true, "placeholder": "How can we help?" } ``` ## select A dropdown whose first entry is an empty “Select an option”. Each option's value equals its label. `defaultValue` selects an exact match. ``` { "id": "plan", "type": "select", "name": "plan", "label": "Plan", "options": ["Starter", "Pro", "Enterprise"], "defaultValue": "{{params.plan}}" } ``` ## radio ``` { "id": "contact", "type": "radio", "name": "contact_by", "label": "Contact me by", "options": ["Email", "Phone"], "required": true } ``` ## rating Star rating. Visitors click or drag across the stars, or use the arrow keys (Home or Delete clears it). The value is submitted as a **number**, such as `3.75`; an unrated optional field submits an empty string. ``` { "id": "score", "type": "rating", "name": "score", "label": "How was your visit?", "required": true, "ratingConfig": { "max": 5, "step": 0.25 } } ``` | `ratingConfig` key | Values | Default | | --- | --- | --- | | `max` | Number of stars, 1–10 | `5` | | `step` | `0.25` (quarter stars), `0.5` or `1` | `0.25` | `defaultValue` sets the starting rating and supports [templates](https://form2wf.com/docs/templates), e.g. `{{data.score}}` to show a stored rating on a data page. ## checkbox A single checkbox. Checked submits `"true"`; **unchecked is omitted entirely**, so treat a missing key as false in n8n. `defaultValue` of `"true"` starts it checked. ``` { "id": "tos", "type": "checkbox", "name": "accept_terms", "label": "Terms", "placeholder": "I agree to the terms of service", "required": true } ``` submits: ``` { "accept_terms": "true" } ``` ## file A drag-and-drop zone for one file. When a file is attached the submission is sent as `multipart/form-data` instead of JSON — see [multipart payloads](https://form2wf.com/docs/webhooks#multipart). Limit: 10 MB per file. ``` { "id": "cv", "type": "file", "name": "resume", "label": "Résumé (PDF)", "required": true } ``` Multipart submissions only carry top-level text values. Don't combine `file` with `object` or `array` fields in the same form — nested values are dropped. ## hidden Sends a value the visitor doesn't see — ideal for tracking the source page or a record id passed in the URL. ``` { "id": "src", "type": "hidden", "name": "source", "defaultValue": "{{params.utm_source}}" } ``` ## display Read-only text, usually filled from the [onload response](https://form2wf.com/docs/data-pages#onload). Rendered as plain text (HTML is not interpreted). ``` { "id": "greet", "type": "display", "label": "Account", "displayValue": "Signed in as {{data.user.name}} ({{data.user.plan}})" } ``` ## Layout: subtitle, divider, linebreak ``` { "id": "h1", "type": "subtitle", "label": "Shipping address" } { "id": "hr", "type": "divider" } { "id": "br", "type": "linebreak" } ``` `subtitle` renders a section heading, `divider` a horizontal rule, and `linebreak` ends the current row in a [multi-column](https://form2wf.com/docs/forms#columns) layout. ## object Groups child fields into a nested object. Optionally collapsible, with a preview of chosen child values while collapsed. ``` { "id": "addr", "type": "object", "name": "address", "label": "Address", "objectConfig": { "collapsible": true, "defaultCollapsed": false, "collapsedPreviewFields": ["city"] }, "children": [ { "id": "street", "type": "text", "name": "street", "label": "Street" }, { "id": "city", "type": "text", "name": "city", "label": "City", "required": true }, { "id": "zip", "type": "text", "name": "zip", "label": "Postal code" } ] } ``` submits: ``` { "address": { "street": "1 Main St", "city": "Oslo", "zip": "0150" } } ``` ## array A repeatable group: visitors add, remove and drag-reorder items. Always shows at least one item. | `arrayConfig` | Default | Description | | --- | --- | --- | | `minItems` | `0` | Items can't be removed below this. | | `maxItems` | unlimited | The add button stops at this count. | | `itemLabel` | `"Item {{index}}"` | Heading for each item; `{{index}}` is 1-based. | | `addButtonLabel` | `"Add Item"` | Text of the add button. | | `allowReorder` | `true` | Drag handles to reorder items. | | `collapsedPreviewFields` | `[]` | Child names shown when an item is collapsed. | ``` { "id": "people", "type": "array", "name": "attendees", "label": "Attendees", "arrayConfig": { "minItems": 1, "maxItems": 10, "itemLabel": "Attendee {{index}}", "addButtonLabel": "Add attendee" }, "children": [ { "id": "pname", "type": "text", "name": "name", "label": "Name", "required": true }, { "id": "pemail", "type": "email", "name": "email", "label": "Email" } ] } ``` submits: ``` { "attendees": [ { "name": "Ada", "email": "ada@example.com" }, { "name": "Grace", "email": "" } ] } ``` ## table Shows rows from the onload response, with optional row actions, a search box (`tableSearch`) and pages (`pageSize`). See [Data pages](https://form2wf.com/docs/data-pages#tables). ``` { "id": "orders", "type": "table", "label": "Recent orders", "dataSource": "orders", "tableSearch": true, "pageSize": 20, "columns": [ { "field": "number", "label": "Order" }, { "field": "total", "label": "Total" } ] } ``` ## markdown Formatted text across the full width of the form: headings, paragraphs, **bold** and _italic_, lists and task lists, tables, quotes, code blocks, links and images. Use it for instructions, terms, a welcome message, or an answer your workflow writes (an AI summary, an article, a status report). It submits nothing. ``` { "id": "intro", "type": "markdown", "markdown": "## Welcome, {{data.user.name}}\n\nUpload your **2026 receipts** below. We'll email you when we're done." } ``` Or take the text from the onload response with `dataSource`, a plain dot path to a markdown string: ``` { "id": "answer", "type": "markdown", "label": "Summary", "dataSource": "summary", "maxHeight": 400 } ``` onload returns: ``` { "data": { "summary": "### Contract review\n\n- **Term:** 24 months\n- **Risk:** auto-renewal clause (section 9)" } } ``` | Property | Meaning | | --- | --- | | `markdown` | The text. Supports templates (`{{data.x}}`, `{{params.x}}`). | | `dataSource` | Path to a markdown string in the onload data. When it has text, it's shown instead of `markdown`. | | `maxHeight` | Pixels (80+). Taller content scrolls inside the field. | | `label` | Optional heading above the text. | HTML inside the markdown is shown as text and never runs. Links may only be `https`, `http`, `mailto`, `tel` or `#anchors`, and open in a new tab; images must be `https` or `http`. That makes it safe to show text that came from visitors or from an AI model. ## nav (menu bar) A full-width menu that turns a set of forms into an app: each item opens one of your forms in place (or an external page in a new tab), and the item for the form being shown is highlighted. Put the same menu at the top of each form in the app. ``` { "id": "menu", "type": "nav", "navItems": [ { "label": "Dashboard", "form": "portal-home" }, { "label": "My requests", "form": "portal-requests", "params": { "status": "open" } }, { "label": "Help", "url": "https://example.com/help" } ] } ``` `form` is a form slug in your organization; `params` are passed like a navigate link's (`{{params.x}}` in the target form, and in its onload webhook). In the builder, add a **Menu Bar** element and write one item per line: `Label | form-slug` or `Label | https://…`. ## chart Draws data from the onload response as a bar, line, area, pie or donut chart, or as a single headline number (`indicator`). Charts take the width their column span gives them and redraw when it changes, so they fit a quarter-width cell as well as a full row. Visitors can hover or use the arrow keys for exact values, and a "Show table" link lists every value. Charts submit nothing. ``` { "id": "sales", "type": "chart", "label": "Sales by month", "colSpan": 2, "chartConfig": { "kind": "bar", "dataSource": "sales", "labelKey": "month", "series": [ { "key": "online", "label": "Online" }, { "key": "store", "label": "In store" } ], "stacked": true, "format": "currency", "currency": "USD" } } ``` onload returns: ``` { "data": { "sales": [ { "month": "Jan", "online": 12400, "store": 8300 }, { "month": "Feb", "online": 13900, "store": 7900 } ] } } ``` | `chartConfig` key | Applies to | Values | | --- | --- | --- | | `kind` | all | `bar` (default), `line`, `area`, `pie`, `donut`, `indicator` | | `dataSource` | all | Dot path to an array in the onload data, like `sales` (not `{{data.sales}}`). Each row is one bar, point or slice. | | `labelKey` | bar, line, area, pie, donut | Row key for the category or slice name. Default: the first text key. | | `series` | all | Number keys to plot: `["online"]` or `[{ "key": "online", "label": "Online" }]`. Several series make a grouped or stacked bar chart or a multi-line chart. Default: every number key. Pie and donut use the first; indicator uses it for its trend line. | | `data` | all | Static rows instead of `dataSource`, for a chart that never changes. | | `horizontal`, `stacked` | bar | `true` for horizontal bars or stacked series. | | `valueLabels` | bar | Values at the bar ends (one series, or stacked totals). Default `true`. | | `smooth`, `markers` | line, area | `smooth: true` curves the line. `markers`: `true` or `false`; by default dots show for up to 12 points. | | `sliceLabels` | pie, donut | `percent` (default), `value` or `none`. Labels only appear where they fit. | | `centerLabel` | donut | Text under the total in the hole. Default `Total`. | | `value` | indicator | A template such as `{{data.visitors}}`. Empty = the last row of `dataSource`. | | `compareTo`, `compareLabel`, `goodWhen` | indicator | Shows the change as a percentage: `compareTo` is a template or `previous` (the row before the last). `compareLabel` e.g. `vs last month`. `goodWhen`: `up` (default, increases are green), `down` or `none`. | | `caption` | indicator | A short line under the number. | | `format` | all | `auto` (default: 1610.01 → 1.6K, 99.6912 → 99.7), `compact` (1.6K), `number` (1,610.01), `percent` (fractions: 0.997 → 99.7%), `currency` ($1,610.01; $48.2K from 10,000). Axes, labels and tooltips use it too. | | `decimals`, `currency`, `prefix`, `suffix` | all | Fixed decimal places (default automatic); ISO currency code (default `USD`); text before or after every number, e.g. `"suffix": " kg"`. For values already in 0–100, use `"suffix": "%"` instead of `percent`. | | `palette`, `colors` | all | `theme` (default: your theme's primary colour first), `default`, `vivid`, or `custom` with `colors: ["#2a78d6", "#eb6834"]`. Default and vivid are tested to stay distinguishable with colour blindness. Extra series beyond the palette are not drawn; extra pie slices are combined into "Other". | | `height` | bar, line, area, pie, donut | Pixels, 120–800. Defaults: 260, pie and donut 240, horizontal bars grow with the rows. | | `table` | bar, line, area, pie, donut | `false` hides the "Show table" link. | headline number: ``` { "id": "visitors", "type": "chart", "label": "Visitors", "chartConfig": { "kind": "indicator", "value": "{{data.visitors}}", "compareTo": "{{data.visitorsLastMonth}}", "compareLabel": "vs last month" } } ``` ## button Triggers a webhook, opens another form, opens a web page, or goes back. Consecutive buttons and links render in one row. See [Buttons](https://form2wf.com/docs/data-pages#buttons). ``` { "id": "approve", "type": "button", "label": "Approve", "buttonAction": "webhook", "buttonStyle": "primary", "buttonWebhookUrl": "https://n8n.example.com/webhook/approve?id={{params.id}}", "buttonWebhookMethod": "POST" } { "id": "open", "type": "button", "label": "Open", "buttonAction": "url", "buttonUrl": "https://files.example.com/{{data.file.path}}" } ``` A `url` button opens `buttonUrl` in a new tab, like a link with `linkAction: "url"`. Templates work in it; only `https`, `http`, `mailto` and `tel` URLs open. ## link ``` { "id": "help", "type": "link", "label": "Pricing details", "linkAction": "url", "linkUrl": "https://example.com/pricing?plan={{params.plan}}" } { "id": "next", "type": "link", "label": "Continue to step 2", "linkAction": "navigate", "linkTargetForm": "signup-step-2", "linkTargetParams": { "email": "{{params.email}}" } } ``` `linkAction`: `navigate` (default — load another form of yours by slug), `back`, or `url` (opens in a new tab). ## Not supported Conditional visibility, min/max/pattern validation rules, multi-select dropdowns, and options with separate values and labels. Validate important rules in your n8n workflow. --- Source: https://form2wf.com/docs/fields — Form2Workflow documentation (Building forms) --- # Data pages, tables & actions Load data from n8n when a form opens, show it in fields and tables, and act on it with buttons and row actions. ## Onload webhook Set an **Onload Webhook URL** in Form Settings. Each time the form loads, form2wf calls it and hands the response to the widget. n8n receives (POST): ``` { "formId": "3f1c…", "formSlug": "customer-list", "params": { "status": "active" }, "endUserId": null, "endUser": null, "metadata": { "timestamp": "…", "userAgent": "…", "ip": "…", "origin": "https://your-site.com" } } ``` `params` holds the page's URL query parameters plus any values passed by navigation (a row action, a navigate button or link, a menu item), so an edit form can look up its record. On a [Verified or Protected](https://form2wf.com/docs/access) form, `endUser` identifies who is signed in — use it to return only their data. With the GET method, `params` arrive as query-string values plus a `_metadata` JSON string. In n8n, set the Webhook node's **Respond** option to _Using 'Respond to Webhook' node_ and reply with JSON: n8n responds: ``` { "data": { "user": { "name": "Ada", "plan": "Pro" }, "orders": [ { "number": "A-1001", "total": "$40" } ] } } ``` The widget uses `data` (or the whole response if there is no `data` key). Fields read it with `{{data.user.name}}`; tables read it by path. If the onload call fails the form still renders, without data. **Testing it:** in the form's Settings, _Call webhook_ (under the onload webhook) runs the workflow once with the URL, method and headers in the dialog, saved or not, plus any URL params you type (e.g. `id=42`). It shows the HTTP status, the time taken and exactly what the form will read as `data`, or why the call failed. The payload's `metadata.test` is `true`, so a workflow can skip side effects during tests. The reply also fills the field editor's data source and key suggestions and lets the builder draw charts with your real data. ## Pre-filling a form config — edit profile, pre-filled from onload: ``` { "fields": [ { "id": "who", "type": "display", "label": "Account", "displayValue": "{{data.user.email}}" }, { "id": "name", "type": "text", "name": "name", "label": "Name", "defaultValue": "{{data.user.name}}" }, { "id": "phone", "type": "tel", "name": "phone", "label": "Phone", "defaultValue": "{{data.user.phone}}" }, { "id": "uid", "type": "hidden", "name": "user_id", "defaultValue": "{{data.user.id}}" } ] } ``` ## Tables `dataSource` is a **plain dot path** into the onload data — `orders` or `account.orders`, not `{{data.orders}}`. If the onload data is itself an array, the table uses it directly. Columns are `{ "field", "label" }` pairs; `field` may be a dot path. Empty tables show “No data available”. ``` { "id": "tbl", "type": "table", "label": "Customers", "dataSource": "customers", "columns": [ { "field": "name", "label": "Name" }, { "field": "email", "label": "Email" }, { "field": "company.name", "label": "Company" } ], "tableSearch": true, "pageSize": 25 } ``` `tableSearch: true` adds a search box that filters rows by any column; `pageSize` shows that many rows at a time with previous/next buttons. Both work on the rows the onload webhook returned, so for very large lists, have the workflow return a sensible subset (for example, filtered by a `{{params.q}}` value). ## Row actions Add `rowActions` to a table to put buttons on every row. | Property | Description | | --- | --- | | `id` | Required, unique. | | `label` | Button text. | | `type` | `webhook` (default) or `form`. | | `style` | `primary`, `secondary` (default) or `danger`. | | `confirmMessage` | Asks for confirmation first. | | `webhookUrl`, `webhookMethod`, `webhookHeaders` | For `webhook` actions. The URL supports `{{row.x}}`; methods GET/POST/PUT/PATCH/DELETE. | | `targetForm`, `params` | For `form` actions: slug of the form to open, and values to pass it. | rowActions: ``` "rowActions": [ { "id": "edit", "label": "Edit", "type": "form", "style": "primary", "targetForm": "edit-customer", "params": { "id": "{{row.id}}", "name": "{{row.name}}", "email": "{{row.email}}" } }, { "id": "delete", "label": "Delete", "type": "webhook", "style": "danger", "confirmMessage": "Delete this customer?", "webhookUrl": "https://n8n.example.com/webhook/customers/delete?id={{row.id}}", "webhookMethod": "DELETE" } ] ``` A `webhook` action sends the **raw row object** as the body, then redraws the form in place so the table shows fresh data (unless the reply [redirects or navigates](#replies)). A `form` action opens the target form in place; its fields can use the passed values as `{{params.id}}`, `{{params.name}}` and so on, and its onload webhook receives them in `params` — so it can load the full record by id instead of you passing every value. ## Buttons | Property | Values | | --- | --- | | `buttonAction` | `webhook` (default), `navigate`, `url`, `back` | | `buttonStyle` | `primary` (default), `secondary`, `danger` | | `buttonWebhookUrl`, `buttonWebhookMethod`, `buttonWebhookHeaders` | For `webhook`. URL supports templates. | | `buttonTargetForm`, `buttonTargetParams` | For `navigate`: a form slug in your organization, and params to pass. | | `buttonUrl` | For `url`: a web page opened in a new tab. Supports templates; only http(s), mailto and tel. | A webhook button sends `{ …onload data, …current input values }` as the body and shows the response's `data` (text, or JSON) as a brief notification — or follows the reply's instructions, below. ``` { "id": "resend", "type": "button", "label": "Resend invoice", "buttonAction": "webhook", "buttonStyle": "secondary", "buttonWebhookUrl": "https://n8n.example.com/webhook/invoices/resend", "buttonWebhookMethod": "POST", "buttonWebhookHeaders": [ { "key": "X-Api-Secret", "value": "…" } ] } ``` ## Replies that redirect, navigate or refresh A workflow behind a **submit**, a **button** or a **row action** can tell the page what to do next by adding these keys at the top level of its JSON reply: | Key | Effect | | --- | --- | | `redirectUrl` | Sends the visitor to that `https` page — for example a Stripe Checkout URL the workflow just created. | | `navigate` | `{ "form": "slug", "params": { … } }` opens another of your forms in place, like a navigate link. | | `refresh` | `true` redraws the form with fresh onload data. After a submit, the form shows again (handy for an "add" form above a table) instead of the thank-you message. | | `message` | Text for the visitor: the success message after a submit, or a notification after a button or row action. | n8n replies to a submit: take the visitor to payment: ``` { "redirectUrl": "https://checkout.stripe.com/c/pay/cs_live_…", "message": "Taking you to payment…" } ``` n8n replies to an approve button: ``` { "message": "Approved", "refresh": true } ``` n8n replies to a quote form: show the quote: ``` { "navigate": { "form": "quote-result", "params": { "quoteId": "Q-1042" } } } ``` ## Complete example: customer admin Two forms and three small n8n workflows give you a list / edit / delete admin page. form: customers (no submit webhook, onload → list-customers): ``` { "showTitle": true, "fields": [ { "id": "t", "type": "table", "label": "Customers", "dataSource": "customers", "columns": [ { "field": "name", "label": "Name" }, { "field": "email", "label": "Email" } ], "rowActions": [ { "id": "edit", "label": "Edit", "type": "form", "targetForm": "edit-customer", "style": "primary", "params": { "id": "{{row.id}}", "name": "{{row.name}}", "email": "{{row.email}}" } }, { "id": "del", "label": "Delete", "type": "webhook", "style": "danger", "confirmMessage": "Delete?", "webhookUrl": "https://n8n.example.com/webhook/customers/delete?id={{row.id}}", "webhookMethod": "POST" } ] } ] } ``` form: edit-customer (submit webhook → update-customer): ``` { "fields": [ { "id": "id", "type": "hidden", "name": "id", "defaultValue": "{{params.id}}" }, { "id": "name", "type": "text", "name": "name", "label": "Name", "defaultValue": "{{params.name}}", "required": true }, { "id": "email", "type": "email", "name": "email", "label": "Email", "defaultValue": "{{params.email}}", "required": true }, { "id": "back", "type": "link", "label": "Back to list", "linkAction": "back" } ] } ``` The n8n side of this example is in [n8n recipes](https://form2wf.com/docs/n8n-recipes#crud). --- Source: https://form2wf.com/docs/data-pages — Form2Workflow documentation (Building forms) --- # Templates & navigation Insert URL parameters, onload data and table rows into fields, links and webhook URLs — and move between forms without reloading. ## Template syntax | Template | Value | | --- | --- | | `{{params.name}}` | A URL query parameter (`?name=…`) or a value passed by navigation. | | `{{data.user.email}}` | A path into the onload response's `data`. | | `{{user.email}}` | Same as above, without the `data.` prefix. | | `{{row.id}}` | The clicked table row (row actions only). | | `{{index}}` | Item number, in `array` item labels. | Paths use dots. Missing values — and the values `0` and `false` — render as an empty string. ## Where templates work | Works | Does not work | | --- | --- | | `defaultValue`, `displayValue`, `linkUrl`, `buttonUrl`, `linkTargetParams`, `buttonTargetParams`, row-action `params`, `buttonWebhookUrl`, row-action `webhookUrl` (values are URL-encoded) | The form's submit webhook URL and onload webhook URL, labels, placeholders, options | Button and row-action webhook URLs are filled in on the server, and the result must stay on the same host you configured — a template can change the path or query, never the destination. ## Passing values in the URL Any page that embeds a form can pass values through its query string: link to your page: ``` https://your-site.com/signup?plan=Pro&utm_source=newsletter ``` ``` { "id": "plan", "type": "select", "name": "plan", "label": "Plan", "options": ["Starter", "Pro"], "defaultValue": "{{params.plan}}" } { "id": "src", "type": "hidden", "name": "source", "defaultValue": "{{params.utm_source}}" } ``` ## Navigating between forms Links with `linkAction: "navigate"`, buttons with `buttonAction: "navigate"` and `form` row actions replace the current form with another of your forms, in the same container, without a page load. The browser's back button (and `linkAction: "back"`) returns to the previous form. multi-step signup: ``` // Step 1 { "id": "go", "type": "link", "label": "Next: company details", "linkAction": "navigate", "linkTargetForm": "signup-company", "linkTargetParams": { "email": "{{params.email}}" } } // Step 2 (form "signup-company") { "id": "email", "type": "hidden", "name": "email", "defaultValue": "{{params.email}}" } ``` From your own JavaScript: `f2wf.navigateToForm("signup-company", { email: "ada@example.com" })`. Navigation params reach templates, but not the target form's onload webhook, and they aren't added to the page URL. See [Known limitations](https://form2wf.com/docs/known-limitations). --- Source: https://form2wf.com/docs/templates — Form2Workflow documentation (Building forms) --- # Themes Give forms your brand colors and spacing. Themes are managed by admins and applied per form. ## Theme properties | Property | Default | Styles | | --- | --- | --- | | `primary_color` | `#2551ed` | Submit and sign-in buttons, primary buttons and row actions, input focus ring, links, file drop zone hover. | | `secondary_color` | `#6b7280` | Secondary buttons and row actions. | | `danger_color` | `#ef4444` | Danger buttons and row actions. | | `text_color` | `#111827` | Title, labels, subtitles, display values, table headers. | | `background_color` | `#ffffff` | Form background (and the hosted page background). | | `column_gap`, `row_gap` | `1` (rem) | Spacing of the field grid. | | `is_default` | — | Pre-selected for new forms in the editor. One per organization. | Button text turns black or white automatically for contrast. ## Applying a theme A form uses a theme when its config has `theme_id`. The editor sets this when you pick a theme; through the API or MCP, set it inside `config`: ``` { "theme_id": "8d2e…", "showTitle": true, "fields": [ … ] } ``` Forms without `theme_id` use the built-in colors (and legacy `buttonColor` if set). ## Creating a theme with an agent MCP: create_theme: ``` { "name": "Brand", "primary_color": "#0f766e", "secondary_color": "#64748b", "danger_color": "#dc2626", "text_color": "#0f172a", "background_color": "#ffffff", "is_default": true } ``` Deleting a theme doesn't check forms that reference it in `config.theme_id`; those forms silently fall back to the built-in colors. Reassign forms before deleting a theme. ## Custom CSS For styling beyond themes, pass `disableStyles: true` to `f2wf.load()` and style the widget's classes yourself (theme colors are still injected). See [Embedding](https://form2wf.com/docs/embedding#styling). --- Source: https://form2wf.com/docs/themes — Form2Workflow documentation (Building forms) --- # Embedding & hosted pages Put a form on any website with one script tag, or share its hosted page when you have no website at all. ## The embed snippet Click **Embed** on a form to copy its snippet. It has three parts: a container, the widget script, and a `load` call with your organization and form slugs. html: ```html
``` Load `f2wf.js` with a normal ` ``` ## Limit which websites can use a form By default any website can embed a form. To restrict it, list your sites under **Form Settings → Allowed Websites** (one domain per line, or `allowed_domains` through the MCP server). Subdomains are included: `example.com` also allows `shop.example.com`. Loading, submitting, onload and button calls from other sites are refused with `403`. The hosted page always works. This stops other websites from using the form in visitors' browsers. A script outside a browser can claim to be any site, so protect sensitive workflows with login-protected forms or a secret webhook header as well. ## Hosted pages Every active form is also available as a standalone page: ``` https://form2wf.com/forms/your-org/contact ``` The page shows your organization name, uses the theme's background and primary colors, and passes its own query string to the form — so `…/forms/your-org/contact?plan=Pro` fills `{{params.plan}}`. Use it for links in emails, QR codes and social bios. The hosted page has its own heading, so set `showTitle: false` on forms mainly shared this way. ## JavaScript API | Method | Description | | --- | --- | | `f2wf.load(org, form, options)` | Render a form into its container. | | `f2wf.navigateToForm(slug, params)` | Replace the current form with another form of the same organization. | | `f2wf.params` | Current params (URL query + navigation params). | | `f2wf.getUser()`, `f2wf.getToken()` | The signed-in form user and their token, on login-protected forms. | | `f2wf.logout()` | Sign the form user out and reload. | | `f2wf.showNotification(message, type)` | Show the widget's toast notification. | ## Styling hooks Every element carries an `f2wf-` class, so you can adjust the look from your own stylesheet: | Class | Element | | --- | --- | | `.f2wf-form`, `.f2wf-fields`, `.f2wf-columns-2`…`-4` | Form, field grid, column variants | | `.f2wf-field`, `.f2wf-title`, `.f2wf-subtitle`, `.f2wf-display-value` | Field wrapper, form title, section heading, display text | | `.f2wf-submit`, `.f2wf-button-primary` / `-secondary` / `-danger`, `.f2wf-link` | Buttons and links | | `.f2wf-table`, `.f2wf-row-action`, `.f2wf-table-empty` | Tables and row actions | | `.f2wf-array-item`, `.f2wf-array-add-btn`, `.f2wf-object-content` | Repeatable and grouped fields | | `.f2wf-file-zone`, `.f2wf-success-message`, `.f2wf-notification`, `.f2wf-user-bar` | File drop zone, success panel, toasts, signed-in bar | css: ``` .f2wf-form { max-width: 560px; margin: 0 auto; } .f2wf-submit { width: 100%; border-radius: 999px; } ``` ## Content Security Policy If your site sends a CSP header, allow the form2wf origin for scripts and requests: ``` Content-Security-Policy: script-src 'self' https://form2wf.com; connect-src 'self' https://form2wf.com; style-src 'self' 'unsafe-inline' ``` --- Source: https://form2wf.com/docs/embedding — Form2Workflow documentation (Publishing) --- # Who can use a form Public, Verified (sign in with Microsoft, no accounts) or Protected (your form users and roles) — and how your workflow knows who it's talking to. ## Three access levels | Level | Who gets in | Accounts to manage | Good for | | --- | --- | --- | --- | | **Public** | Anyone with the page | None | Contact forms, sign-ups, quote requests | | **Verified** | Anyone who signs in with a trusted provider (Microsoft today), optionally only from certain email domains | None — the provider vouches for them | Client portals, partner intake, "staff at acme.com only" tools, spam-free public forms | | **Protected** | Form users you create, by role or individually | Yes, on the Form Users page | Business apps where you decide exactly who has access | Pick the level from the access menu in the form editor's toolbar (it shows the current level: **Public**, **Verified** or **Protected**). **Manage Access** in the same menu holds the settings for the chosen level. Every data call — onload, submit, buttons and row actions — is checked on the server, not just hidden in the page. ## Verified: sign in with Microsoft Visitors see **Continue with Microsoft**. Their browser goes to Microsoft and comes straight back to the same page, signed in. They don't create an account with you, and you have no passwords to manage. Your workflows receive who they are, as confirmed by Microsoft: n8n receives (onload, submit, fetch): ``` { "endUserId": "microsoft:72f988bf-…:a1b2c3…", "endUser": { "id": "microsoft:72f988bf-…:a1b2c3…", "email": "jane@contoso.com", "emailVerified": true, "name": "Jane Doe", "verified": true, "provider": "microsoft", "tenantId": "72f988bf-…" }, "data": { … } } ``` `endUserId` is stable for that person, so use it as the key for their records. Button and row-action webhooks get the same identity in the `X-F2WF-User-Id`, `X-F2WF-User-Email` and `X-F2WF-User-Provider` headers. A sign-in lasts 8 hours, then the visitor signs in again (usually one click). ### Verified settings (Manage Access) - **Sign in with:** the providers this form offers. None ticked = every provider your organization has set up. - **Email domains:** only people whose _provider-verified_ email is at one of these domains (or their subdomains) get in, e.g. `contoso.com`. Leave empty to accept any account. **Embedding a Verified form?** List the websites that embed it under the form's **Settings > Allowed domains**. A sign-in only ever returns to the form's hosted page or to an allowed domain, so another site can't start a sign-in and collect your visitors' identity. ### Set up Microsoft sign-in (about 5 minutes) Your organization uses its own Microsoft app registration, so the Microsoft sign-in screen shows your app's name and logo. 1. In the dashboard, open **Settings > Sign-in Providers** and copy the **Redirect URI**. 2. In the [Microsoft Entra admin center](https://entra.microsoft.com), go to **App registrations > New registration**. Give it your organization's name. Under _Supported account types_, pick who may sign in (any Microsoft account is the most common). Under _Redirect URI_, choose **Web** and paste the URI. 3. On the app's **Overview**, copy the **Application (client) ID**. 4. Under **Certificates & secrets > New client secret**, create a secret and copy its **Value** (not the Secret ID). It's only shown once. 5. Back in Sign-in Providers, paste the client ID and secret, choose **Who can sign in** to match step 2, and save. 6. On a form, choose **Verified** in the access menu and save. **Who can sign in** options: any Microsoft account (work, school or personal), work or school accounts only, personal accounts only, or **one organization only** — enter a Directory (tenant) ID or domain, and only that company's accounts can sign in. That last option is the strongest way to build a tool for one client's staff. Client secrets expire (6–24 months, set when you create them). Put a reminder in your calendar; when you make a new one, paste it into Sign-in Providers. Leave the secret field blank when saving other changes to keep the current one. ### Which emails count as verified Personal Microsoft accounts (Outlook, Hotmail) are verified by Microsoft. For work and school accounts, form2wf uses the account's sign-in name, whose domain the company has proven it owns. The free-text "email" field of a work account is _not_ trusted on its own, because a company's admin can set it to anything. So `emailVerified` and the email-domain list can't be fooled by a made-up address. ## Protected: your form users **Form users** are people who sign in with an email and password you give them. They are separate from dashboard team members and can't open the dashboard. Admins create them on the **Form Users** page (`/dashboard/form-users`) or with the `create_form_user` MCP tool. Access is granted to individual form users, or to **roles** — named groups you manage under **Roles**. 1. In the form editor toolbar, open the access menu and choose **Protected**. 2. From the same menu, open **Manage Access** and choose the roles and form users allowed in. Signed-out visitors see a _Sign in to access this form_ button. After signing in, allowed users see the form with a "Signed in as …" bar; others see an access-denied message. The session lasts 7 days and is shared by every form2wf form on the same website. n8n receives: ``` { "endUserId": "9a0b…", "endUser": { "id": "9a0b…", "email": "client@acme.com", "name": "Acme Ops" }, "data": { "request": "Please reset our router" }, "metadata": { … } } ``` Dashboard team members can sign in to a protected form and view it, but its submit endpoint only accepts form users. Create a form user account for anyone who needs to submit, including staff. ## From an AI agent (MCP) MCP: set_form_access: ``` { "form_id": "3f1c…", "access_mode": "verified", "verified_config": { "providers": ["microsoft"], "emailDomains": ["contoso.com"] } } { "form_id": "3f1c…", "access_mode": "protected", "role_ids": ["…"], "user_ids": [] } ``` Sign-in providers themselves are set up by an admin in the dashboard; their secrets are never available over the API. ## Patterns - **Client portal without accounts:** Verified, email domain = the client's company. The onload webhook returns that person's tickets, keyed by `endUserId`. - **Internal tools:** Verified with your own domain (or "one organization only" in the provider settings) — your staff use their work Microsoft accounts. - **Fine-grained business apps:** Protected, with roles per team. --- Source: https://form2wf.com/docs/access — Form2Workflow documentation (Publishing) --- # JavaScript SDK Use `@form2n8n/sdk` when you want your own HTML and JavaScript instead of the rendered widget. The SDK is not published to npm yet. Until it is, call the submit endpoint directly (below) — it's one `fetch` call. ## Without the SDK The submit endpoint is public and CORS-enabled, so any page can post to it: javascript: ```js const res = await fetch('https://form2wf.com/api/v1/submit/3f1c…form-uuid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Ada', email: 'ada@example.com' }) }); const result = await res.json(); // 200: { "success": true, "data": , "message": "Submitted successfully" } // 403 inactive form · 429 quota or rate limit · 502 n8n failed or timed out ``` Send JSON for plain values, or `FormData` for file uploads (see [multipart payloads](https://form2wf.com/docs/webhooks#multipart)). Submissions are rate-limited to 10 per minute per IP. ## SDK setup javascript: ```js import { Form2n8n } from '@form2n8n/sdk'; const f2 = new Form2n8n({ apiUrl: 'https://form2wf.com/api/v1', // always set this — the built-in default is a placeholder onError: (err) => console.error(err) }); ``` SDK methods take the form's **UUID** (shown in the dashboard URL and returned by `list_forms`), not its slug. ## submit ``` const result = await f2.submit('3f1c…form-uuid', { name: 'Ada', email: 'ada@example.com', message: 'Hello' }); // result = whatever your n8n workflow responded with ``` The object you pass becomes `data` in the [submit payload](https://form2wf.com/docs/webhooks#submit). Unlike the widget, the SDK returns n8n's response body, so you can show a result computed by your workflow. ## bindForm Wire your own `
` element: submission is intercepted and sent as a flat object of its named inputs. ```
``` ## fetch and poll `fetch(formId)` calls the form's submit webhook with an empty POST and returns `data` from n8n's reply — handy for small read-only feeds. `poll(formId, callback, intervalMs)` repeats it and returns a function that stops polling. ``` const items = await f2.fetch('7b9e…form-uuid'); const stop = f2.poll('7b9e…form-uuid', (items) => render(items), 10000); // later: stop(); ``` `fetch` doesn't forward query parameters to n8n and doesn't check login. For parameterized or per-user data, use a form with an [onload webhook](https://form2wf.com/docs/data-pages#onload) instead. ## Form-user auth ``` await f2.auth.login('client@acme.com', 'password', ''); f2.auth.isAuthenticated(); // true await f2.submit('3f1c…', { request: 'Reset router' }, { auth: true }); f2.auth.logout(); ``` ## Reference | Member | Description | | --- | --- | | `new Form2n8n({ apiUrl, onError })` | Create a client. | | `submit(formId, data, { auth, headers })` | Submit data; resolves to n8n's response. | | `fetch(formId, { auth })` | Call the form's webhook for data. | | `bindForm(el, formId, { auth, headers, onSuccess, onError })` | Handle a form element's submit event. | | `poll(formId, callback, intervalMs, options)` | Repeat `fetch`; returns a stop function. | | `auth.login / register / logout / isAuthenticated / getToken` | Form-user session helpers. | --- Source: https://form2wf.com/docs/sdk — Form2Workflow documentation (Publishing) --- # Webhooks & payloads The exact requests form2wf sends to n8n, what n8n should send back, and how failures behave. ## Four kinds of webhook | Webhook | Configured on | Called when | Body | | --- | --- | --- | --- | | Submit | Form settings | A visitor submits | [Wrapped: `data` + `metadata`](#submit) | | Onload | Form settings | The form loads | [`params` + `endUser` + `metadata`](#onload) | | Button | A `button` field | The button is clicked | [Raw: onload data + inputs](#actions) | | Row action | A table's `rowActions` | A row button is clicked | [Raw: the row](#actions) | ## Setting up the n8n Webhook node - Use the **Production URL** (`/webhook/…`) and activate the workflow. Test URLs (`/webhook-test/…`) only work while the editor is listening. - HTTP method must match: submit and onload default to `POST`. - n8n puts the request body under `$json.body` — submitted values are at `{{ $json.body.data.email }}`. - For onload and buttons, set **Respond** to _Using 'Respond to Webhook' node_ so you control the JSON reply. ## Submit payload (JSON) `POST` to the submit webhook with `Content-Type: application/json` plus your [configured headers](#headers): body: ``` { "endpointId": "3f1c2b7a-…", // form UUID "data": { // the visitor's values, nested as in the form "name": "Ada", "email": "ada@example.com", "address": { "city": "Oslo" }, "attendees": [ { "name": "Ada" } ] }, "metadata": { "timestamp": "2026-09-29T14:03:11.000Z", "userAgent": "Mozilla/5.0 …", "ip": "203.0.113.7", "origin": "https://your-site.com" // the embedding site }, "userId": null, "user": null, // set when a signed-in session exists "endUserId": null, "endUser": null // set on Verified and Protected forms } ``` ## Submit payload (multipart, with a file) When a `file` field has a file, the request is `multipart/form-data` and the shape changes — there is no `data` wrapper: | Part | Content | | --- | --- | | each top-level field | Its text value, under its `name` | | the file field | The file, under its `name` (n8n: binary property) | | `_metadata` | JSON string: `endpointId`, `userId`, `user`, `endUserId`, `endUser`, `timestamp`, `userAgent`, `ip`, `origin` | In n8n, text values are at `{{ $json.body.email }}` and the file is in the item's binary data. Multipart requests are not retried, and nested (object/array) values are not included. ## What n8n should return | n8n replies | Visitor sees | API response | | --- | --- | --- | | Any 2xx | Your success message | `200 { success: true, data: }` | | 4xx | An error message | `502 Webhook failed` (not retried) | | 5xx or network error | An error message | `502` after up to 2 retries | | No reply within 30s | An error message | `502` after retries | The widget reads four optional keys from the reply — `redirectUrl`, `navigate`, `refresh` and `message` — to send the visitor to a payment page, open another form, redraw the form, or replace the success message (see [Replies](https://form2wf.com/docs/data-pages#replies)). The API passes them back as `next`, and the [SDK](https://form2wf.com/docs/sdk) and direct API calls also receive the whole reply as `data`. Other status codes you may see from form2wf: `404` unknown form, `403` inactive form or access denied, `429` monthly quota or rate limit reached. **Retries can deliver a submission twice.** If your workflow is slow but eventually succeeds, form2wf may already have retried. Make workflows idempotent — see [Best practices](https://form2wf.com/docs/best-practices#idempotency). ## Securing your webhook Anyone who learns a webhook URL can call it. Add a secret header in form2wf and require it in n8n: 1. In Form Settings → Submit Webhook, add a header such as `X-Form2wf-Secret: `. 2. In the n8n Webhook node, set **Authentication** to _Header Auth_ with the same name and value. Headers are stored server-side and never sent to browsers; the MCP server returns them as `[redacted]`. Button and row-action webhooks take their own headers (`buttonWebhookHeaders`, `webhookHeaders`). ### Webhooks on a local network By default form2wf only calls public internet addresses. Webhook URLs that point at `localhost`, `192.168.x.x`, `10.x.x.x` and other private addresses are refused, and the visitor sees "webhook is on a private network". This stops a form from being used to reach machines on the server's own network. If your n8n runs next to form2wf (for example `http://192.168.1.10:5678/webhook/…`), the organization owner can turn on **Allow LAN webhooks** under Settings → Organization Security. The same setting applies to the n8n server connection. ## Onload payload POST body: ``` { "formId": "3f1c…", "formSlug": "my-orders", "params": { "status": "open" }, "endUserId": "9a0b…", "endUser": { "id": "9a0b…", "email": "client@acme.com", "name": "Acme Ops" }, "metadata": { "timestamp": "…", "userAgent": "…", "ip": "…", "origin": "…" } } ``` With method `GET`, `params` become query-string values and `metadata` is sent as a `_metadata` JSON string (no `endUser`). Reply with `{ "data": { … } }`; a failure returns `502` and the form renders without data. ## Button and row-action payloads These send the raw values, with no wrapper or metadata. The body comes from the visitor's browser, so treat it as untrusted: never let it decide _who_ may change a record. row action body — the row: ``` { "id": 42, "name": "Acme", "email": "ops@acme.com" } ``` button body — onload data merged with current inputs: ``` { "user": { "id": 7 }, "note": "Approved by phone" } ``` Put identifying values in the URL (`?id={{row.id}}`) or in hidden inputs. A button displays the reply's `data` field as a notification; row actions redraw the form after success. Both follow [reply keys](https://form2wf.com/docs/data-pages#replies) such as `redirectUrl` and `message`. On forms that require sign-in (Verified or Protected), the signed-in person is sent in headers that only form2wf sets: `X-F2WF-User-Id` and `X-F2WF-User-Email`, plus `X-F2WF-User-Provider` (e.g. `microsoft`) on Verified forms. Use these in n8n to check that the person may act on the row. ## Execution log Every webhook call — submissions, onload, fetch and row actions — is logged with its status, duration, and n8n's response or error. Query it with the `list_submissions` [MCP tool](https://form2wf.com/docs/mcp-tools), filtering by form, type, status and time; for example `{ "status": "failed", "since": "2026-09-28T00:00:00Z" }` finds every failed delivery across your organization. --- Source: https://form2wf.com/docs/webhooks — Form2Workflow documentation (n8n) --- # n8n workflow recipes Proven workflow shapes for the most common form2wf jobs. Node settings and expressions are given so you can rebuild each in a few minutes. ## Contact form → email workflow: ``` Webhook (POST /contact, Header Auth) → Send Email ``` | Node | Setting | | --- | --- | | Webhook | Respond: _Immediately_. Authentication: _Header Auth_ matching your form's header. | | Send Email | To: your inbox. Reply-To: `{{ $json.body.data.email }}`. Subject: `New message from {{ $json.body.data.name }}`. Text: `{{ $json.body.data.message }}` | ## Lead capture → Google Sheets + Slack workflow: ``` Webhook → Google Sheets (Append row) → Slack (Send message) ``` Map columns from `$json.body.data.*` and record `$json.body.metadata.timestamp` and `$json.body.metadata.origin` so you know which site each lead came from. Add a _hidden_ field with `{{params.utm_source}}` to capture campaigns. ## Pre-fill a form from a database workflow: ``` Webhook (Respond: using Respond to Webhook) → Postgres / MySQL (Select) → Respond to Webhook (JSON) ``` Query with `{{ $json.body.params.id }}` (URL parameter) or, on protected forms, `{{ $json.body.endUser.email }}`. Respond with: Respond to Webhook → Response Body: ``` {{ { "data": { "user": $json } } }} ``` Form fields then use `{{data.user.name}}` as their `defaultValue`. ## Customer admin (list / edit / delete) The n8n side of the [customer admin example](https://form2wf.com/docs/data-pages#example): | Workflow | Nodes | Notes | | --- | --- | --- | | list-customers (onload) | Webhook → Select rows → Aggregate → Respond to Webhook | Respond with `{ "data": { "customers": [...] } }`. Use an _Aggregate_ node to collect rows into one array. | | update-customer (submit) | Webhook → Update row → Respond | Values at `$json.body.data.id / name / email`. | | delete-customer (row action) | Webhook → Delete row → Respond | Id from the query: `{{ $json.query.id }}`; the row itself is the body. | ## File upload → cloud storage workflow: ``` Webhook (Binary property: resume) → Google Drive / S3 (Upload) → Send Email ``` Multipart submissions put text fields at `$json.body.` and the file in the binary property named after the field. Parse `$json.body._metadata` with `JSON.parse` if you need the timestamp or origin. ## Support request → AI triage workflow: ``` Webhook → AI Agent / LLM (classify) → Switch (by category) → Create ticket / Email / Slack ``` Pass `$json.body.data.message` to the model and ask for a category and urgency. On a login-protected form, include `$json.body.endUser.email` so the ticket is tied to a verified customer. ## An n8n AI agent that manages your forms n8n's **MCP Client Tool** node can connect an AI Agent to the form2wf MCP server — for example a scheduled agent that reviews yesterday's failed submissions and reports them. Settings: | Field | Value | | --- | --- | | Endpoint | `https://form2wf.com/mcp` | | Server Transport | HTTP Streamable | | Authentication | Header Auth — name `Authorization`, value `Bearer f2w_…` | | Tools to include | Selected — e.g. `list_forms`, `list_submissions` (use a viewer key) | ## Making workflows idempotent Because failed deliveries are retried, build a dedupe key from the submission and skip repeats: Code node — dedupe key: ``` const b = $json.body; return [{ json: { ...b, dedupeKey: `${b.endpointId}:${b.metadata.timestamp}:${b.data.email ?? ''}` } }]; ``` Then use _Remove Duplicates_ (across executions) or an upsert on that key before sending emails or creating records. --- Source: https://form2wf.com/docs/n8n-recipes — Form2Workflow documentation (n8n) --- # MCP server & API keys Connect Claude, Cursor, VS Code, n8n AI agents or any Model Context Protocol client to your organization, with a scoped API key. ## What you can do The form2wf MCP server gives an AI agent the same abilities you have in the dashboard, limited to one organization and one access level. Useful prompts: - “Create a job application form with a résumé upload that posts to https://n8n.example.com/webhook/jobs, then give me the embed code.” - “Which forms had failed submissions this week, and what did n8n return?” - “Add a required phone field after the email field on the contact form.” - “Build a customer list page from my list-customers webhook with Edit and Delete actions.” - “Create a Brand theme with primary #0f766e and apply it to every form.” ## Server details
Endpointhttps://form2wf.com/mcp
TransportStreamable HTTP, stateless (POST only; no SSE stream, no session)
AuthenticationAuthorization: Bearer f2w_… (or X-API-Key: f2w_…)
Tools24 — see the tool reference
Rate limit60 requests per minute per key owner
## Creating an API key 1. In the dashboard, open **API Keys** in the sidebar. You must be an organization **admin** or **owner**. 2. Click **Create API Key**, name it after where it will be used (“Claude Code — laptop”, “n8n reporting agent”), and choose an access level. 3. Copy the key. It is shown **once** — form2wf stores only a hash. The dialog also shows a ready-to-paste Claude Code command. ### Access levels | Level | Acts as | Can | | --- | --- | --- | | viewer | User | Read forms and their config, webhook call history, usage, folders, themes, roles, form users, form access; get embed snippets. | | editor | Creator | Viewer, plus create and update forms (fields, layout, webhooks, active flag) and manage folders. | | admin | Admin | Editor, plus delete forms, change form access, create and update themes, create form users and assign them to forms. | A key acts as the member who created it, and **never exceeds that member's current role**: demote them and their admin keys drop to their new level; remove them from the organization and their keys stop working. No key can create, list or revoke API keys, manage the team or touch billing. ### Revoking a key Click the trash icon next to a key on the API Keys page. Revocation is immediate and permanent. The page shows each key's preview (`f2w_1a2b…9f0e`), creator and last-used time, so you can spot unused keys. ## Connecting a client **Claude Code** terminal: ```bash claude mcp add --transport http form2wf https://form2wf.com/mcp \ --header "Authorization: Bearer f2w_your_key_here" ``` Add `--scope user` to make it available in every project, or `--scope project` to share it through `.mcp.json` (keep the key itself out of version control — see below). Check with `claude mcp list`, then ask Claude to “run whoami on form2wf”. Install the [form2wf Claude Code skill](https://form2wf.com/docs/claude-skill) too — it teaches Claude the safe editing sequence and the gotchas. **Claude Desktop** Edit `claude_desktop_config.json` (Settings → Developer → Edit Config) and restart Claude Desktop. Requires Node.js. claude_desktop_config.json: ```json { "mcpServers": { "form2wf": { "command": "npx", "args": ["-y", "mcp-remote", "https://form2wf.com/mcp", "--header", "Authorization:${F2W_AUTH}"], "env": { "F2W_AUTH": "Bearer f2w_your_key_here" } } } } ``` The header value lives in `env` because some systems split arguments containing spaces. **Cursor** ~/.cursor/mcp.json: ```json { "mcpServers": { "form2wf": { "url": "https://form2wf.com/mcp", "headers": { "Authorization": "Bearer f2w_your_key_here" } } } } ``` **VS Code** VS Code can prompt for the key instead of storing it in the file: .vscode/mcp.json: ```json { "inputs": [ { "type": "promptString", "id": "f2w-key", "description": "form2wf API key", "password": true } ], "servers": { "form2wf": { "type": "http", "url": "https://form2wf.com/mcp", "headers": { "Authorization": "Bearer ${input:f2w-key}" } } } } ``` **n8n AI Agent** Add an **MCP Client Tool** node to an AI Agent: | Field | Value | | --- | --- | | Endpoint | `https://form2wf.com/mcp` | | Server Transport | HTTP Streamable | | Authentication | Header Auth — name `Authorization`, value `Bearer f2w_…` | See [n8n recipes](https://form2wf.com/docs/n8n-recipes#agent-mcp) for an example. **Other / raw HTTP** Any client that supports remote Streamable HTTP servers with custom headers works. To test from a terminal: curl: ```bash curl -s https://form2wf.com/mcp \ -H "Authorization: Bearer f2w_your_key_here" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whoami","arguments":{}}}' ``` Because the server is stateless, every request stands alone — `initialize` is optional for raw calls. ## Security - **Least privilege:** reporting and support agents get viewer. Use admin only for agents that must delete forms or manage access. - **One key per agent or machine**, named clearly, so you can revoke one without breaking the others. - **Keep keys out of git.** Use your client's secret/prompt features or environment variables. If a key leaks, revoke it and create a new one. - **Secrets stay hidden:** webhook header values are never returned through MCP — tools show `[redacted]`, and writing the placeholder back is rejected so an agent can't wipe a real secret. - **Review destructive actions:** most clients ask before running tools; approve `delete_form` and `set_form_access` deliberately. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | HTTP 401, “API key required” | No key sent, or the header isn't `Authorization: Bearer f2w_…` / `X-API-Key`. | | HTTP 401, “invalid or has been revoked” | Typo, or the key was revoked. Create a new one. | | HTTP 403 on connect | The key's creator left the organization or was deactivated. Create a new key as a current admin. | | Tool result with status 403 | The key's access level (or its creator's role) is too low for that tool. | | HTTP 405 | The client tried to open an SSE stream (`GET`). Use a client with Streamable HTTP support, or `mcp-remote`. | | HTTP 429 | More than 60 requests a minute. Slow the agent down. | | `FORM_QUOTA_EXCEEDED` | Your plan's form limit is reached. See [Plans & limits](https://form2wf.com/docs/limits). | --- Source: https://form2wf.com/docs/mcp — Form2Workflow documentation (AI agents) --- # MCP tool reference All 24 tools, the minimum access level each needs, and their arguments (`*` = required). ## Organization | Tool | Level | Arguments | Description | | --- | --- | --- | --- | | `whoami` | viewer | — | Organization, key name, effective access level and what it allows. Call first. | | `get_config_reference` | viewer | — | The complete form config reference (field types, properties, templates, payloads). Read before building forms. | | `get_usage` | viewer | `period`: 24h | 7d | 30d | Monthly submission quota (used / limit / reset date) and webhook call counts by type and outcome. | ## Forms | Tool | Level | Arguments | Description | | --- | --- | --- | --- | | `list_forms` | viewer | `folder_id` (or "none"), `search` | Summaries: id, name, slug, folder, active, webhook flags, field count, hosted URL. | | `get_form` | viewer | `form_id` | Full form with `config`. Header values redacted. | | `get_embed_snippet` | viewer | `form_id` | Embed HTML and hosted page URL. | | `create_form` | editor | `name`\*, `slug`, `n8n_webhook_url`, `webhook_headers`, `onload_webhook_url`, `onload_webhook_method`, `onload_webhook_headers`, `config`, `folder_id` | Create a form. Subject to the plan's form quota. | | `update_form` | editor | `form_id`\*, any create field, `is_active` | Only sent fields change; `config` is replaced whole. Omit headers to keep them. | | `delete_form` | admin | `form_id` | Permanent. Prefer `update_form` with `is_active: false`. | ## Webhook history | Tool | Level | Arguments | Description | | --- | --- | --- | --- | | `list_submissions` | viewer | `form_id`, `type`: submit | onload | action | fetch | all, `status`: success | failed, `since`, `limit`, `offset` | Every webhook call with request, n8n response or error, duration. Omit `form_id` to search the organization. | ## Folders | Tool | Level | Arguments | Description | | --- | --- | --- | --- | | `list_folders` | viewer | — | Folders used to organize forms. | | `create_folder` | editor | `name` | Create a folder. | | `rename_folder` | editor | `folder_id`, `name` | Rename a folder. | | `delete_folder` | editor | `folder_id` | Delete a folder; its forms move to the top level. | ## Themes | Tool | Level | Arguments | Description | | --- | --- | --- | --- | | `list_themes` | viewer | — | Themes and which is default. | | `create_theme` | admin | `name`\*, colors, `column_gap`, `row_gap`, `is_default` | Create a theme. Apply it with `config.theme_id`. | | `update_theme` | admin | `theme_id`\*, any theme field | Update a theme. | ## Access | Tool | Level | Arguments | Description | | --- | --- | --- | --- | | `list_roles` | viewer | — | Form-access roles. | | `list_form_users` | viewer | `search`, `status` | People who sign in to protected forms. | | `get_form_access` | viewer | `form_id` | Login requirement and allowed roles/users. | | `set_form_access` | admin | `form_id`\*, `auth_required`\*, `role_ids`, `user_ids` | Require login and choose who may access. Lists replace existing ones. | | `create_form_user` | admin | `email`\*, `password`\* (8+), `name` | Create a form user. | | `assign_form_user` | admin | `user_id`\*, `form_id`\* | Grant a form user access to a form. | | `unassign_form_user` | admin | `user_id`\*, `form_id`\* | Remove that access. | ## Results and errors Tools return JSON text. A failure is returned as a tool result with `isError: true` and the HTTP-style status and message, for example: ``` { "status": 403, "error": "Insufficient permissions", "code": "INSUFFICIENT_PERMISSIONS", "message": "This action requires admin role or higher. You have creator role." } ``` Invalid arguments are rejected by the MCP layer before anything runs. ## Recommended workflow for agents 1. `whoami` — know the organization and level. 2. `get_config_reference` — once, before building or editing forms. 3. `list_forms` → `get_form` — read before you write. 4. `create_form` / `update_form` with the complete `config`. 5. `get_embed_snippet` — hand the result to the user. 6. `list_submissions` — verify deliveries after the user tests. --- Source: https://form2wf.com/docs/mcp-tools — Form2Workflow documentation (AI agents) --- # Claude Code skill A ready-made skill that teaches Claude Code how to use the form2wf MCP server safely: the build sequence, the editing rules that prevent data loss, and the gotchas. ## Install Skills live in a folder with a `SKILL.md` file. Install it for all your projects: **macOS / Linux** terminal: ```bash mkdir -p ~/.claude/skills/form2wf-mcp curl -fsSL https://form2wf.com/docs/skills/form2wf-mcp/SKILL.md -o ~/.claude/skills/form2wf-mcp/SKILL.md ``` **Windows (PowerShell)** powershell: ```powershell New-Item -ItemType Directory -Force "$HOME\.claude\skills\form2wf-mcp" | Out-Null Invoke-WebRequest https://form2wf.com/docs/skills/form2wf-mcp/SKILL.md -OutFile "$HOME\.claude\skills\form2wf-mcp\SKILL.md" ``` **One project only** terminal (in your project): ```bash mkdir -p .claude/skills/form2wf-mcp curl -fsSL https://form2wf.com/docs/skills/form2wf-mcp/SKILL.md -o .claude/skills/form2wf-mcp/SKILL.md ``` Then [connect the MCP server](https://form2wf.com/docs/mcp#connect). Claude loads the skill automatically when you mention form2wf, forms that post to n8n, or use the form2wf tools. [Download SKILL.md](https://form2wf.com/docs/skills/form2wf-mcp/SKILL.md) ## What's in it SKILL.md: ``` --- name: form2wf-mcp description: Use when building, inspecting or managing Form2Workflow (form2wf) forms through its MCP server — creating forms that post to n8n webhooks, editing form config/fields, data pages with onload webhooks and tables, embed snippets, submissions, themes, form users and form access. Triggers on form2wf, Form2Workflow, f2wf, "form that posts to my n8n webhook", f2w_ API keys, or the mcp__form2wf__* tools. --- # form2wf MCP form2wf renders forms and data pages on any website and sends submissions to n8n webhooks. Its MCP server lets you manage one organization's forms. ## Connect - Endpoint: `https:///mcp` — stateless Streamable HTTP, POST only. - Auth: `Authorization: Bearer f2w_...` (or `X-API-Key: f2w_...`). Keys are created by an org admin in the dashboard under **API Keys**; the raw key is shown once. - Claude Code: ```bash claude mcp add --transport http form2wf https:///mcp \ --header "Authorization: Bearer f2w_..." ``` - Call `whoami` first: it returns the org, the key's access level and what it can do. ## Access levels | Level | Can | |---|---| | viewer | Read everything: forms, submissions, usage, folders, themes, roles, form users, access, embed snippets | | editor | viewer + create/update forms, manage folders | | admin | editor + delete forms, set form access, create/update themes, create form users, assign them to forms | A key never exceeds the current role of the member who created it. A 403 in a tool result means the key's level is too low — say so; don't retry. ## Tools - Read: `whoami`, `get_config_reference`, `list_forms`, `get_form`, `get_embed_snippet`, `list_submissions`, `get_usage`, `list_folders`, `list_themes`, `list_roles`, `list_form_users`, `get_form_access` - Editor: `create_form`, `update_form`, `create_folder`, `rename_folder`, `delete_folder` - Admin: `delete_form`, `set_form_access`, `create_theme`, `update_theme`, `create_form_user`, `assign_form_user`, `unassign_form_user` ## Building a form — the reliable sequence 1. `get_config_reference` once per session. Don't guess config keys; there are exactly five top-level keys (`showTitle`, `columns`, `theme_id`, `buttonColor`, `fields`). 2. Ask the user for the n8n **production** webhook URL (`/webhook/...`, not `/webhook-test/...`). Without `n8n_webhook_url` the form has no submit button. 3. `create_form` with `name`, `n8n_webhook_url`, and `config.fields`. Give every field a unique `id`. 4. `get_embed_snippet` and hand the user the HTML and the hosted URL. 5. Suggest a secret header: `webhook_headers: [{"key":"X-Form2wf-Secret","value":""}]` plus Header Auth on the n8n Webhook node. ## Editing — avoid destroying data - `update_form` replaces `config` **as a whole**. Always `get_form`, modify the returned `config`, send the complete object. Sending only `fields` drops `columns`, `showTitle` and `theme_id`. - Header values come back as `"[redacted]"`. Omit `webhook_headers` to keep them; sending the placeholders is rejected. - Changing `slug` breaks every existing embed and link — confirm with the user first. - Prefer `update_form` with `is_active: false` over `delete_form` (deletion is permanent and removes submission history). Confirm before deleting anything. ## Gotchas - Forms have a UUID `id` (tools, submit API) and a `slug` (embeds, hosted URL, `targetForm`, navigation). Don't mix them up. - Submitted values reach n8n under `body.data` (JSON). With a `file` field the request is multipart with flat fields and no `data` wrapper — don't combine `file` with `object`/`array` fields. - Table `dataSource` is a plain dot path (`customers`), not `{{data.customers}}`. - Templates (`{{params.x}}`, `{{data.x}}`, `{{row.x}}`) work in `defaultValue`, `displayValue`, link/button params and button/row-action webhook URLs — NOT in the form's `n8n_webhook_url` or `onload_webhook_url`. - A target form's onload webhook only sees the page URL's query params, not params passed by navigation/row actions. Pass needed values as row-action `params` and use them as `defaultValue: "{{params.x}}"`. - n8n's reply to a submission is not shown to the visitor; success text comes from the embed's `f2wf.load(..., { successMessage })` options. - Failed deliveries (5xx/timeout) are retried up to twice — tell users to make workflows idempotent. - Unchecked checkboxes are omitted from submitted data. - Rate limit: 60 MCP requests per minute per key owner. Form quota depends on plan (Free: 5 forms, 1,000 submissions/month) — `create_form` returns `FORM_QUOTA_EXCEEDED` when full. ## Debugging a form that "doesn't work" 1. `get_form` — is `is_active` true? Is `n8n_webhook_url` set and a production URL? 2. `list_submissions` — look at `status` and the stored n8n response/error. A 503 means n8n failed, timed out, rejected the call (check its header auth), or the webhook is on a private network address (refused unless the org allows LAN webhooks). 3. `get_form_access` — a protected form needs the visitor to be an assigned form user, or a member of the form's organization. ``` --- Source: https://form2wf.com/docs/claude-skill — Form2Workflow documentation (AI agents) --- # Plans & limits Quotas by plan, rate limits, sizes and timeouts. ## Plans | Plan | Price | Submissions / month | Forms | Team members | | --- | --- | --- | --- | --- | | Free | $0 | 1,000 | 5 | 1 | | Pro | $29 / month | 10,000 | Unlimited | 10 | | Enterprise | Custom | Unlimited | Unlimited | Unlimited | - Creating a form beyond the limit returns `403 FORM_QUOTA_EXCEEDED`. - Submissions beyond the monthly limit return `429 SUBMISSION_LIMIT_EXCEEDED` with the limit, usage and reset date. Only successful deliveries count; the counter resets at the start of each month. - Check your position any time with the `get_usage` MCP tool or the dashboard overview. ## Rate limits | Scope | Limit | Keyed by | | --- | --- | --- | | Submissions (`/submit`) | 10 per minute | IP | | Loading forms, onload, buttons, row actions, hosted pages | 120 per minute | IP | | Dashboard API and MCP | 60 per minute | User (the key's creator for MCP) | | Sign-in attempts | 10 per 15 minutes | IP, and per email for dashboard logins | | Everything | 500 per 15 minutes | IP | Exceeding a limit returns `429` with `retryAfter`. ## Sizes and timeouts
Uploaded files5 MB per submission on Free, 10 MB on Pro and Enterprise; up to 10 files. Larger uploads are refused with 413 UPLOAD_TOO_LARGE before they're read.
JSON body10 MB
n8n response time30 seconds, then up to 2 retries for JSON submissions on 5xx/network errors
Form-user session7 days
API keys50 active per organization
--- Source: https://form2wf.com/docs/limits — Form2Workflow documentation (Reference) --- # Best practices Guidance for secure, reliable forms and workflows — and for working with AI agents. ## Security - **Protect every webhook with a secret header** (Header Auth in n8n). Webhook URLs aren't shown to browsers, but they can leak through logs and screenshots. - **Validate in n8n.** `required` is enforced by the browser only. Check required values, formats and allowed options in your workflow before acting on them. - **Trust identity from `endUser`, not form fields.** On protected forms, form2wf tells you who signed in; an email typed into a field proves nothing. - **Scope API keys tightly** — viewer for reporting, one key per agent, revoke unused keys. - **Treat submitted text as untrusted** when inserting it into HTML emails, SQL or AI prompts. ## Reliability - **Make workflows idempotent.** A slow workflow can receive the same submission twice because of retries. Deduplicate on a key such as form id + timestamp + email before side effects ([recipe](https://form2wf.com/docs/n8n-recipes#idempotency)). - **Reply fast.** Respond to the webhook first and do slow work (AI calls, large uploads) afterwards, well inside 30 seconds. - **Use production webhook URLs** and keep workflows active; test URLs stop working when the n8n editor closes. - **Watch for failures** — a scheduled n8n agent or a quick question to your MCP-connected assistant (“any failed submissions today?”) catches broken workflows before customers do. ## Form design - **Give every field an `id`** and never reuse `name`s within a level. - **Keep slugs stable** once a form is embedded; rename the display name instead. - **Deactivate instead of deleting** — `is_active: false` keeps history and can be undone. - **Prefer several small forms** linked with navigation over one huge form; each gets its own webhook and is easier to change. - **Handle missing checkboxes** — unchecked means absent, not `false`. - **Keep file forms flat** — don't combine `file` with `object`/`array` fields. - **Pass values forward explicitly** between forms via params and `defaultValue` templates. ## Working with AI agents - Install the [Claude Code skill](https://form2wf.com/docs/claude-skill) so the agent follows the read-modify-write sequence for `config`. - Ask the agent to show you the planned `config` before creating large forms. - Approve destructive tools (`delete_form`, `set_form_access`) deliberately. - Let agents verify their work: after you submit a test, “check `list_submissions` for the last delivery”. ## Performance - Keep onload responses small — return only the fields and rows the page shows. - Load `f2wf.js` once per page even with several forms. - Cache slow lookups in n8n (e.g. a Redis or data-table node) for onload webhooks called on every page view. --- Source: https://form2wf.com/docs/best-practices — Form2Workflow documentation (Reference) --- # Known limitations Current behaviors that may surprise you, with workarounds. We list these so you can design around them. | Limitation | Workaround | | --- | --- | | The `onSuccess` callback in `f2wf.load()` currently throws after a successful submission, which triggers the error path. | Don't use `onSuccess` for now; rely on the built-in success message. | | Button and row-action webhooks send no metadata, and the signed-in person only on Verified and Protected forms (as `X-F2WF-User-*` headers). | On public forms, put identifying values in the URL or hidden fields and verify them in n8n. | | Multipart (file) submissions drop nested values and aren't retried. | Keep file forms flat. | | Roles accept dashboard members only, so form users can't be added to roles. | Assign form users to forms directly. | | One sign-in is shared by all form2wf forms on the same website. | Use one organization per website where users differ. | | The SDK's `fetch` doesn't forward query parameters and ignores login. | Use a form with an onload webhook for parameterized or per-user data. | | Table search and paging work on the rows the onload webhook returned; there is no server-side paging. | Return a filtered subset for very large lists (e.g. by a `{{params.q}}` value). | | Verified sign-in offers Microsoft only so far. | Use Protected (form users) for people without a Microsoft account. | | No conditional fields, pattern validation or multi-select. | Split into multiple forms; validate in n8n. | | Deleting a theme doesn't block forms that use it; they fall back to default colors. | Reassign forms before deleting a theme. | | The hosted page shows the form name as a page heading, so the form title appears twice. | Set `showTitle: false` on forms shared mainly by link. | | The “Powered by Form2Workflow” footer shows on all plans. | — | --- Source: https://form2wf.com/docs/known-limitations — Form2Workflow documentation (Reference)