Skip to content
MocklyDocs

Build

Forms

Forms that post to your site, record a goal, and forward submissions to a webhook.

A <Form> posts its fields to the site it's on, so you don't need a server of your own to receive them. The site stores each submission, records the form's goal for the visitor who sent it, and posts the submission to a webhook if you've set one. Forms work with or without JavaScript.

You read submissions on the studio's Forms page, and choose there where else they go: a webhook, or your email.

In the visual editor, Add has a Contact form section (a name, an email, and a message) and a Newsletter section (an email and a button), each a <Form> ready to publish.

Add a form#

pages/contact.tsx
import { Form, Page } from "mockly"; export default function Contact() {  return (    <Page title="Contact · Acme">      <main class="mx-auto max-w-md p-8">        <h1 class="text-2xl font-bold">Talk to sales</h1>        <Form name="contact" goal="demo-request" success={<p>Thanks. We'll reply within a day.</p>} class="mt-6 space-y-4">          <label class="block">            Work email            <input type="email" name="email" required class="mt-1 block w-full rounded border px-3 py-2" />          </label>          <label class="block">            Message            <textarea name="message" rows="4" class="mt-1 block w-full rounded border px-3 py-2"></textarea>          </label>          <button type="submit" class="rounded bg-gray-900 px-4 py-2 text-white">            Send          </button>        </Form>      </main>    </Page>  );}

Fields are sent by their name, so give every input you want to receive one. Fields that share a name, such as a group of checkboxes, arrive as a list.

Props#

PropMeaning
name (required)Names the form in the studio, where its submissions and webhook are. Lowercase letters, digits, and dashes, as a string literal.
goalThe goal a submission records. Defaults to the form's name.
redirectWhere visitors go after submitting: a page path such as "/thanks", or an http or https URL. Without it, the form shows success.
successMarkup or text shown in place of the form once it's sent. Defaults to "Thanks! Your form was sent."
errorShown when sending fails. The form stays, so the visitor can try again. Without it, the form says why when the site gives a reason, such as too many submissions at once, and otherwise "Something went wrong. Please try again."
Anything elseA <form> attribute, such as class, id, novalidate, or aria-label. action, method, enctype, and target aren't allowed, because <Form> sets them itself (MK2003).

Several <Form>s can share a name, on one page or on several, if they have the same goal and redirect (MK4014). They're one form in the studio. Forms can't be nested (MK4015).

The goal#

A submission records the form's goal on the server, as part of storing it, so it counts with or without JavaScript. The goal counts as tracked, so an experiment can use it: an experiment with goal="demo-request" counts the visitors who send the form above. See Goals.

What happens on submit#

With JavaScript, the form is sent in the background and dims while it's sending; pressing Enter again meanwhile doesn't send it twice. When the site has stored it, the success message takes the form's place and gets focus, or the visitor goes to redirect. If sending fails, the error message appears below the fields, and the form keeps what the visitor typed.

Without JavaScript, the browser posts the form itself. The site answers with a redirect: to redirect, or back to the same page, which shows the success message in place of the form. A failure comes back to the page with the error message.

In the studio's preview, nothing is sent: the form shows its success message.

Check fields before sending#

Inside an island, a <Form> takes event handlers like any element. An onSubmit handler that calls e.preventDefault() stops the submission:

components/signup-form.tsx
import { Form, Show, state } from "mockly"; export function SignupForm() {  const email = state("");  const tried = state(false);  return (    <Form      name="signup"      redirect="/welcome"      novalidate      onSubmit={(e) => {        tried.value = true;        if (!email.value.includes("@")) {          e.preventDefault();        }      }}    >      <input        type="email"        name="email"        aria-label="Email"        value={email.value}        onInput={(e) => {          email.value = e.currentTarget.value;        }}      />      <Show when={tried.value && !email.value.includes("@")}>        <p class="text-sm text-red-600">Enter an email address.</p>      </Show>      <button type="submit">Sign up</button>    </Form>  );}

The success and error messages can read the island's state too, such as the email the visitor typed. See Interactive parts.

Read submissions#

Open a site in the studio and choose Forms in the header. Each form the site has had is a tab, with its status as a dot and how many submissions it has. A form opens with its numbers: the last 7 days against the 7 before, all its submissions, its spam, and how its webhook is doing. Below them are its submissions, newest first, a line each: the email (or name) they were sent with, the form's next few fields, where the visitor came from, when, and whether the webhook got it. Load more shows older ones, and the search finds what visitors typed, in any field, or the page it was sent from.

Click a submission to open it at the side, with every field in full and where its visitor came from: the site that sent them or their utm_ tags, the page they landed on, their country and device, and the variant of each experiment they saw. So you can tell which variant brought in a lead. It's kept from the visitor's visit as the form was sent; a visitor without Mockly's cookie has none.

Check submissions, or open one, to mark them as spam, or to delete them. Deleting can't be undone, and only an organization's owners and admins can. Keep submissions, in the form's Settings, deletes ones older than 30 days, 90 days, or a year each day; by default they're kept until deleted. Deleting submissions leaves their goals in the site's analytics.

CSV downloads every submission in the folder, with a column for each field, then where each visitor came from, and the submission's ID. Cells that a spreadsheet would read as a formula are escaped.

A form you've removed from the site's pages stays on the list with its submissions, until an owner or admin deletes it.

Email#

In a form's Settings, check Email me new submissions to get its submissions at your account's email address. Each person turns it on for themselves. Submissions that arrive close together come in one email, at most one every 10 minutes for a form, so a busy form doesn't flood your inbox. Spam is never emailed. Emails need the studio's email server; without one, none are sent, and the form says so.

Mockly doesn't keep a copy of an email once it's sent, or given up on: what it said stays on the Forms page alone, so deleting a submission there takes it out of Mockly. Copies already emailed, or sent to a webhook, are wherever they went.

Webhooks#

A webhook sends each submission to another service as it arrives, such as Zapier, Make, a Slack channel, or your own server. Set it in the form's Settings, under Webhook: paste the URL, then Save. The URL must use https:// on the standard port, with no user name or password in it, and reach a public address: Mockly never sends submissions into a private network. Only an organization's owners and admins can set or change a webhook, or see its signing secret. Members see where submissions go, as the address's first part (https://hooks.slack.com/…): the rest of a Slack or Zapier address lets anyone who has it post there.

Webhooks live in the studio, not in your source, because a webhook URL is often a credential, and where submissions go is up to the site's owner. A form keeps its webhook across edits and publishes. Send test posts a sample submission, with "test": true, so you can check the receiver before going live. It has the published form's own fields, with sample values, so a service such as Zapier that learns fields from a sample learns the right ones.

Slack: paste an incoming webhook URL (https://hooks.slack.com/services/…) and each submission arrives in its channel as a message: its fields, where the visitor came from, and a link to the Forms page. Slack's own markup in a visitor's words, such as a <!channel> mention, shows as plain text.

The payload#

Each submission is posted as JSON:

JSON
{  "id": "0b8f3f6e-5d0c-4c1e-9a57-2f1d8f0c6a44",  "event": "form.submitted",  "site": "acme/launch",  "form": "contact",  "page": "/contact",  "submittedAt": "2026-10-20T14:03:12.511Z",  "data": { "email": "ada@example.com", "topics": ["pricing", "support"] },  "visit": {    "referrer": "news.ycombinator.com",    "utmSource": null,    "utmMedium": null,    "utmCampaign": null,    "entryPage": "/pricing",    "country": "DE",    "device": "desktop",    "variants": { "hero-copy": "bold" }  },  "test": false}
FieldMeaning
idThe submission's ID. Every attempt to deliver it sends the same one, so you can skip a submission you've already handled.
eventAlways "form.submitted".
site, formThe site's address, its organization's slug and its own, and the form's name.
pageThe page the form was sent from.
submittedAtWhen the site received it, in ISO 8601.
dataThe fields, in the order they were sent. Each value is a string, or a list of strings for a repeated name.
visitWhere the visitor came from: the referring site, utm_ tags, the page they landed on, their country and device, and the variant of each experiment they saw. null for a visitor without Mockly's cookie.
testtrue for Send test.

The request also carries the headers Mockly-Event: form.submitted, Mockly-Delivery with the same id, and a signature.

Verify the signature#

When you save a webhook, the form gets a signing secret, which starts with whsec_. It's shown below the URL, hidden until you reveal it, and it stays the same when you change the URL. If it may have got out, Replace makes a new one; deliveries are signed with it from then on, so give it to your receiver. Each request has a Mockly-Signature header:

Text
Mockly-Signature: t=1792504993,v1=bf3210255d051ad71e72ec3f49cc0b1f53407a2348e8ead69aa11947d2cf8a59

t is when the request was sent, in Unix seconds. v1 is the HMAC-SHA256 of the text <t>.<body>, keyed with the signing secret, in hex. To check it in Node.js:

TypeScript
import { createHmac, timingSafeEqual } from "node:crypto"; // body: the request body exactly as received, before parsing it as JSON.export function isFromMockly(body: string, header: string, secret: string): boolean {  const parts = new Map(header.split(",").map((part) => part.split("=") as [string, string]));  const t = parts.get("t") ?? "";  // Refuse requests signed more than five minutes ago, so a copied one can't be replayed.  if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;  const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest();  const given = Buffer.from(parts.get("v1") ?? "", "hex");  return given.length === expected.length && timingSafeEqual(given, expected);}

Delivery and retries#

The site posts to the webhook after it has answered the visitor, so nobody waits on your receiver. A delivery succeeds when the receiver answers with a 2xx status within 10 seconds. Redirects aren't followed, so a 3xx answer counts as a failure.

A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours: six tries over about 15 hours, so a receiver that's down for a while still gets every submission. Each submission shows how it went: Stored when the form has no webhook, Sending, Retrying with when, Delivered, or Failed, with the receiver's status or why it couldn't be reached. Open it to see every try. Send again sends one once more, and Retry failed tries every failed one again, once.

Spam and limits#

Spam that's certain is dropped quietly. A form has a hidden field that people never see; a submission that fills it in, or that comes from a known bot, gets the normal success answer, and nothing is stored or sent.

Spam that's likely goes to the form's Spam folder instead of its inbox: a submission sent within a second and a half of its page opening, faster than a person fills in a form, or with three or more links. It isn't sent to the webhook, emailed, or counted as a goal. If one wasn't spam, check it and choose Not spam: it moves to the inbox, counts its goal at the time it arrived, and goes to the webhook and your email, unless it already had. Mark as spam does the reverse, and takes its goal back.

The form's page says how many submissions it turned away in the last 30 days, and why.

LimitValue
Submissions10 a minute from one client to one site, and 60 a minute to one site in all; more get the error message
Size64 KB per submission
Fields50, with names of up to 100 characters and values of up to 10,000
FilesForms can't upload files