How Forms Work

A Leed form is a definition, not a page and not code: a name, a handful of settings, and an ordered list of fields. You build it in the CMS. When you publish it, Leed writes that definition into your site repository as data, and a Handlebars partial renders it into real HTML the next time your site builds. Nothing is captured until both halves of that are true — the form is published, and a page that calls the partial is live.

This page follows one submission the whole way, so that when a lead looks “missing” you know which of the seven or eight things between the visitor’s browser and your inbox to check.

A lead-capture form rendered on a published Leed site, showing its heading, four fields including Email, and a submit button

The path a submission takes

sequenceDiagram
    autonumber
    participant V as Visitor
    participant F as leed/form partial
    participant C as POST /api/clerk
    participant D as formFillEvents in D1
    participant L as lead_details
    participant Q as FORM_EVENT_QUEUE
    participant N as Email worker
    V->>F: Fills in the rendered form and submits
    F->>C: POST JSON — fields, formId, pageId, timezone, knownUser
    C->>C: Verify the Turnstile token, record the verdict
    C->>D: Write the submission row
    C->>L: formFillUserOptIn — upsert contact, record opt-in
    C-->>V: 200 — ok, sent, or a download URL
    C->>Q: Enqueue a form-fill event
    Q->>N: Response email
    Q->>N: Internal notifications
  1. The visitor submits. Every form on a Leed site carries the class LeedForm, and the site’s own JavaScript binds its onsubmit handler on page load. The submit button is disabled immediately so a double-click cannot post twice.
  2. The browser posts JSON to /api/clerk. The payload is the form’s own field values plus four things the page knows and the visitor does not type: the formId, the page and page-type ids, the browser’s IANA timezone, and whether Leed already recognizes this visitor.
  3. Turnstile is checked, and the verdict is recorded. When your workspace has a Turnstile private key configured, the token is verified against Cloudflare and the result is stored on the submission as verifiedUser. It is a note in the record, not a gate — see Spam protection and Turnstile.
  4. The form is looked up. If the formId does not resolve to a form in your workspace, the request fails with 400 and nothing is stored. This is the check that stops another site’s traffic landing in your data.
  5. The row is written first. The submission lands in the formFillEvents table before anything else happens. Everything downstream is derived from that row, which is why a submission can exist with no contact attached but never the other way round.
  6. A contact is created or updated — but only if the submission carried an email address, or Leed already knew the visitor. This is the branch that surprises people most; it has its own section below.
  7. The browser gets an answer. Plain acknowledgment (ok), “Email Sent!” when a gated asset is on its way, or a download URL when the form has a gated asset and Leed already recognizes the visitor. The button swaps to the matching state label.
  8. The notifications go out on a queue. The response email and your team’s internal notification are handled asynchronously by a worker, after the response has already reached the visitor. A few seconds of delay here is normal and is not a failure.

Where each piece lives

The single most useful thing to hold in your head is that a form exists in three places at once — as a CMS record, as published data in your Git repository, and as markup on a page — and only the first of those changes when you hit save.

PieceWhere it livesWritten byRead by
Form definitionform:<companyId>:<formId> in Leed’s key-value storeThe form builder in Design → FormsThe CMS, and /api/clerk on every submission
Published definitionsrc/_data/leedForms.json in your site repositoryA deployment with reason Forms, merged one entry per formIdThe site build
Rendered markupThe leed/form partial, shipped by the site builderThe site buildYour visitor’s browser
Submission rowformFillEvents in D1POST /api/clerkThe form’s Submissions tab
Contactlead_detailsformFillUserOptIn, during the requestEngage, and every email send
Marketing opt-inlead_opts, stamped source: "form"the same callThe send-email worker’s opt-out check
Response email batchlead_batch, batch type confirmationThe form-fill event handlerThe send-email worker

The published definition is part of your site’s global data — the same generated src/_data/ directory that holds your menus, labels and page types. It is rendered by the leed/form partial, which looks the formId up in that file and renders nothing at all if it is not there.

What a form is made of

A form record is small. Its name and optional description are for you, and never reach the site. Its prototype is a slug that becomes a CSS class on both the wrapper and the <form> element, so a single stylesheet can cover every form that shares it. Its heading and details render above the fields, and the button label on the submit button.

The success and failure callbacks name global JavaScript functions on your site that Leed calls with the formId after a submission resolves — conventions, not code Leed provides. Leed Autofill decides how much of the form a returning visitor even sees, and the separate Autofill setting can hand the job to LinkedIn instead. All of that, plus every field and every per-field option, is documented in Building a form and the Form field reference.

Two settings act after the submission rather than before it: an email template to send back to the person who filled the form, and an asset to attach to it. Those are how you gate a download, and Response emails and gated downloads covers the rules — including the one that catches everybody, which is that the response email is sent once per person per form and never again.

Placing the form on a page is its own short topic, because there are three different ways to do it and they are chosen by three different people. And nothing you change in the builder reaches visitors until the next deployment — publishing changes is a deliberate, separate step.

What happens when the visitor gives you an email

An email address is what turns an anonymous session into a person. When a submission carries one — or when Leed already recognizes the visitor from an earlier fill — three things happen inside the request, before the browser gets its answer:

  • A contact is upserted into lead_details. The same email through any of your forms updates the same contact, which is why the standard fields matter: email from your contact form and email from your newsletter form are the same column, not two.
  • A marketing opt-in is recorded in lead_opts, stamped source: "form" and tagged with the formId that produced it. That row is what later lets you email this person; a lead with no opt-in cannot be sent to.
  • The browsing history already collected for that anonymous session is attached to the new contact. The visitor did not become interesting at the moment they typed their address — Leed just learned who had been reading. Lead profiles and visitor identity explains what that record then looks like, and Contacts is where they show up.

There is one deliberate exception. If Leed already knew this person from something other than a form — a contact upload, the API, an MCP client, email engagement, a CRM sync — a form submission does not overwrite their stored details. The upload stays authoritative; the submission is still recorded, still opts them in, and still notifies you. Only a contact that Leed originally learned from a form gets refreshed by a later form.

Contact, lead, submission and opt-in are four different objects with four different lifetimes, and this page keeps them apart on purpose. The glossary has one-line definitions if you want them side by side.

Four places the chain stops

Each of these is intentional, and each has produced a support question.

flowchart TD
    A["Row written to formFillEvents"] --> B{"Email supplied,<br/>or visitor already known?"}
    B -- Yes --> C["Upsert contact in lead_details"]
    C --> D["Record marketing opt-in"]
    D --> E["Enqueue form-fill event"]
    E --> F["Response email + internal notifications"]
    B -- No --> G["Nothing further:<br/>no contact, no opt-in,<br/>no notification"]

A preview site captures nothing. Forms render on a preview deployment so you can check the layout, but /api/clerk answers a preview request with 204 and discards the payload. The button still switches to its “Thank you!” state, so a preview submission looks successful — and neither your success nor your failure callback runs, because the 204 branch returns before either is reached. Test the layout on preview; test the capture on the live site. Preview site vs live site covers the rest of the differences.

A submission with no email from a visitor Leed does not recognize creates nothing. The row is written to formFillEvents and appears on the Submissions tab, and that is the end of it: no contact, no opt-in, no internal notification, no response email. If you have a form whose fills you can see but whose notifications never arrive, check whether the form actually has an Email field on it.

Turnstile records a verdict but never rejects one. The result is stored on the submission as verifiedUser and the fill is saved either way. This is genuinely different from how the same challenge behaves on the CMS sign-in screens, where it blocks. Spam protection and Turnstile is blunt about what that means for you.

A response email goes out once per contact per form, ever. Leed keeps one confirmation batch per form and adds each contact to it once; a second submission from the same person finds their entry already marked sent and no second email is sent. The gated download is still delivered — for a recognized visitor the file comes back directly in the response rather than by email — but the email itself does not repeat. Response emails and gated downloads explains how to work with that.

ESC