---
title: "How forms work"
description: "Sign-up forms put people into your audiences. Build one here, share it as a link, embed it on any site, or post to it from your own code."
url: "https://nextmail.sh/docs/api/forms/overview"
area: "API"
category: "Mailbox"
---

# How forms work

Sign-up forms put people into your audiences. Build one here, share it as a link, embed it on any site, or post to it from your own code.

## A draft and a live copy

A form keeps two copies of what visitors see. `document` is the draft you edit, and `publishedDocument` is what the hosted page, the embed and the subscribe endpoint use. Saving changes only the draft, and `POST /forms/{id}/publish` copies it live. `hasUnpublishedChanges` tells you the two differ.

- `draft`: never published. Nobody can see it or sign up through it.
- `live`: published and taking sign-ups.
- `paused`: published but closed. The page shows the closed message from its copy and sign-ups are refused.

`settings` are different: where sign-ups go, double opt-in, the sender, the thank-you behaviour and who is told about each sign-up. They apply as soon as they are saved, published or not.

## Fields

A document is a list of `fields`, the `copy` around them and a `style`. Each input field has a `key`, which is the name its answer is posted under: a lower-case letter followed by up to 39 lower-case letters, digits or underscores, unique in the form, and never starting with `oe_`. Every form has exactly one `email` field, keyed `email` and required.

- Inputs: `email`, `text`, `textarea`, `number`, `phone`, `url` and `date`.
- Choices: `select`, `radio` and `checkboxes`, each with `options`.
- `checkbox` for a yes or no, and `consent` for a box that has to be ticked when it is required.
- `audiences` lets the person choose lists: each option `value` is an audience id in this workspace.
- `hidden` carries a value the visitor never sees: the one your page posts, or else its `defaultValue`, such as a campaign name.
- `heading`, `paragraph` and `divider` only lay the form out and post nothing.

Set `mapsTo` on a field and its answer is saved on the contact the sign-up creates: `firstName`, `lastName`, `name`, `company` or `jobTitle` on a text field, `phone` on a phone field, `website` on a URL field and `birthday` on a date field. A contact that already exists keeps its name and whatever its card already holds, and a phone number or website it already lists is not added twice. Every answer is kept on the submission, with the label it had, so old submissions still read right after the form changes.

Set `saveAs` on a field to save its answer to a custom field of the contact, such as `plan`, which a broadcast reads as `{{attributes.plan}}` and a segment can match. Each answer replaces the field of that name, an empty answer leaves it alone, and a choice of several options is saved as their labels joined by commas. It works on any field that takes an answer except the email and `audiences`, one field per custom field, and not together with `mapsTo`.

## Putting a form on a page

Publish first. Then use whichever of the three suits the page. All of them reach the same form and count the same sign-ups. Views are counted only on the hosted page and the embed, so sign-ups through your own HTML or code raise the conversion rate.

- The hosted page at `url`, a page of its own you can link to from anywhere.
- The embed script, which puts the form on your page in a frame that sizes itself.
- Your own HTML or code, posting the answers to `subscribeUrl`.

**Embed**

```
<script src="https://nextmail.sh/embed/form.js" data-signup-form="frm_3b9d2e7a1c4f80d56e2a9b14" async></script>
```

**HTML**

```
<form action="https://api.nextmail.sh/subscribe/frm_3b9d2e7a1c4f80d56e2a9b14" method="post">
  <input type="email" name="email" required>
  <div style="position:absolute;left:-9999px" aria-hidden="true">
    <input type="text" name="oe_website" tabindex="-1" autocomplete="off">
  </div>
  <button type="submit">Subscribe</button>
</form>
```

A plain HTML form is redirected to the thank-you page, or to `settings.redirectUrl`. Code that posts JSON gets a JSON answer instead, described on the subscribe page.

## Double opt-in

With `settings.doubleOptIn` on, a sign-up is stored as `pending` and the person is emailed a link from `settings.senderAddress`, an address of this workspace. They join the audiences when they open it. The link works for seven days. A person who unsubscribed earlier is only subscribed again this way, never by a single opt-in form: a form without double opt-in sends them the same confirmation email instead, when it has a sender address. Signing up again before confirming updates the pending sign-up rather than adding another.

To protect the people you email, one address gets at most one confirmation per form every ten minutes and five a day across the workspace. You can approve a pending sign-up yourself, or send it a fresh link.

A new form has double opt-in on, sending from the first address you may send as. Turn it off in the form settings, or send `settings.doubleOptIn: false`, for a form that adds people at once. Forms made before this keep their setting.

## Who can see what

- Reading needs `forms:read` and changing needs `forms:write`. Approving a sign-up also needs `contacts:write`, because it adds a contact.
- Anything that makes a form send mail also needs `emails:send`: turning on double opt-in, setting the sender or the confirmation email, publishing or resuming a double opt-in form, and resending a confirmation.
- An API key and the owner see every form in the workspace. An app a member connected sees only the forms that member made, and only the audiences that member made plus the built-in ones.
- Creating, updating, publishing, resuming or duplicating a form whose sender or notify addresses lie outside what a limited key or app may reach answers 422 `capability_unsupported`.
- A key or app limited to some addresses may only set a sender and notify addresses it holds.
- Deleting a form asks an OAuth app for a verification code, as other destructive changes do. An API key never needs one.

`form.submitted` and `form.confirmed` webhooks tell your systems about every sign-up. A webhook limited to some addresses never receives them, because sign-ups belong to the whole workspace.

## Bots and limits

- A field named `oe_website` is a trap for bots: keep it empty and off screen, as the HTML above does. A sign-up that fills it gets a normal answer and is dropped.
- The hosted page and the embed also check a signed start time, and a form sent back faster than a person could fill it in is dropped the same way.
- One network can post 40 sign-ups in ten minutes, across all your forms and whatever the outcome. After that JSON callers get 429 `form_rate_limited`, and a plain HTML form goes to the hosted page with `?outcome=limited`.
- A workspace holds 100 forms by default.
- A form can list the sites that may use it, in its Settings or as `settings.allowedSites`: a domain (`example.com`), every subdomain of one (`*.example.com`), or either with a path (`example.com/pricing`, `example.com/blog/*`). The embed and the iframe then show only on those sites, and a post from any other site answers 403 `form_site_not_allowed`. A browser names its site by itself, a server names the page in `oe_source`, and the form's own page always works. This keeps other websites out. It is not a secret, since anything that is not a browser can send any address.

## From code, the terminal and agents

Everything here is also in the SDK as `nextmail.forms` and in the CLI as `nextmail forms`, and the MCP server has form tools, so an agent can build, publish and watch a form. Over MCP the client writes the design itself and passes it as `document`.

Posting to `subscribeUrl` from your own code needs no credential. Send the answers as JSON, add the page the form was on as `oe_source`, leave out `oe_started`, and send `oe_website` empty or not at all. Every sign-up from one network shares the limit of 40 every ten minutes, so a server relaying sign-ups for many people reaches it quickly: add people you already know with the audience import instead.
