Forms
A form is how people who aren’t in your app yet become contacts. You build a form in Letter, share its hosted page or embed it on your site, and every submission creates (or updates) a contact, drops them into one or more lists, and can kick off a sequence: all on one timeline.
Forms are the top of the funnel; lists group the contacts they collect, and sequences nurture them.
Anatomy of a form
Hosted page: https://forms.letter.app/f/<publicId>
Every form has two identifiers: an internal id (used by the dashboard, CLI,
and Management API) and a publicId (the hosted URL slug). Both are returned
when you read a form.
A form is an ordered list of fields plus delivery config:
| Field | What it is |
|---|---|
fields | The inputs, in display order. Exactly one is the email field (always present, can’t be removed): it’s the contact key. |
title / show_title | Optional heading shown above the form. |
submit_label | Text on the submit button (e.g. “Subscribe”). |
target_list_ids | One or more lists every confirmed submission joins. Required to publish. |
double_opt_in | When on, a contact must click a confirmation email before they’re added and any trigger fires. |
success_message / redirect_url | What happens after submit: show a message, or send them to a URL. |
hide_badge | Hide the “Powered by Letter” badge on the hosted page. |
Fields
Every field maps onto a contact trait keyed by its key (the email field is
special: it becomes the contact’s externalId). Field types:
email- the required contact key. Exactly one, always present.text- short single-line input.textarea- long multi-line input.select- dropdown; needs at least oneoptionbefore you can publish.checkbox- boolean.paragraph- display-only copy shown between inputs (collects nothing).
A fields array looks like this:
[
{ "key": "email", "label": "Work email", "type": "email", "required": true },
{ "key": "first_name", "label": "First name", "type": "text", "required": true },
{
"key": "company_size",
"label": "Company size",
"type": "select",
"required": false,
"options": ["1-10", "11-50", "51-200", "200+"]
}
]
Keys are normalized server-side to lowercase a-z0-9_ (so First Name becomes
first_name). Whatever a contact submits lands on their traits under that key,
ready to segment on.
Embedding
Every form works three ways, same backend either way:
<!-- 1. Script loader: drops a styled form anywhere -->
<script src="https://forms.letter.app/f/<publicId>.js"></script>
<!-- 2. Iframe: fully isolated, zero CSS conflicts -->
<iframe src="https://forms.letter.app/f/<publicId>"></iframe>
<!-- 3. Plain HTML POST: bring your own markup -->
<form action="https://forms.letter.app/f/<publicId>" method="post">
<input name="email" type="email" />
<button type="submit">Subscribe</button>
</form>
The hosted page also works on its own: just share the link.
Spam protection
Submissions are protected without annoying real people: a hidden honeypot field, optional Cloudflare Turnstile, and per-IP rate limiting. Bots and junk submissions are dropped before they ever become a contact.
Double opt-in
With double_opt_in on, a submission first creates a pending contact and
sends a branded confirmation email. The contact is only added to the target
lists (and any list_entered / form_submitted sequence trigger only fires)
once they click the confirmation link. This keeps your lists clean and
compliant. With it off, the contact is added immediately on submit.
Publishing
A form starts as a draft. Publishing flips its status to published and
makes the hosted page live. Before a form can be published it must have:
- at least one list in
target_list_ids, and - an
optionon everyselectfield.
Publishing is just a status change (see the CLI and API below): the same checks run wherever you publish from.
From submit to sequence
A submission isn’t a dead-end row. Once a contact is confirmed, two trigger types can enroll them into a sequence automatically:
form_submitted- fires for the specific form. Best when the form itself is the intent signal (a “book a demo” form starts the sales sequence).list_entered- fires when the contact joins a target list. Best when several entry points (a form, a CSV import, the dashboard) all feed the same onboarding.
Both are documented in the Sequences guide. You wire them up there, referencing the form or list id.
Lists and forms
A list is a static contact grouping with explicit membership. Forms point at
one or more lists via target_list_ids; you can also manage membership directly
(dashboard, CSV import, CLI, or API). Adding a contact to a list emits the
list_entered event, so list membership and sequence enrollment stay in sync no
matter how the contact got there.
Managing forms from the CLI
The CLI exposes full form and list management over the Management
API. Field lists and id arrays are JSON, so they accept an inline string or
@file.json:
# Lists
letter lists list # all lists + member counts
letter lists create --name "Newsletter"
letter lists members lst_123 --limit 100
letter lists add-member lst_123 --contact alice@acme.com
letter lists remove-member lst_123 --contact alice@acme.com
# Forms
letter forms list
letter forms create --name "Welcome form"
letter forms update frm_123 --fields @fields.json --target-list-ids '["lst_123"]'
letter forms update frm_123 --double-opt-in true --submit-label "Subscribe"
letter forms update frm_123 --status published # needs >=1 target list
letter forms submissions frm_123 --status confirmed
letter forms duplicate frm_123
Managing forms from the Management API
Everything above maps onto REST endpoints under your project (see the Management API for auth and conventions):
| Method | Path | What it does |
|---|---|---|
GET | /v1/projects/{slug}/forms | List forms (with submission counts). |
POST | /v1/projects/{slug}/forms | Create a draft form. |
GET | /v1/projects/{slug}/forms/{id} | Get one form’s full config. |
PATCH | /v1/projects/{slug}/forms/{id} | Update fields/config; set status: "published" to publish. |
DELETE | /v1/projects/{slug}/forms/{id} | Delete a form. |
POST | /v1/projects/{slug}/forms/{id}/duplicate | Copy a form into a new draft. |
GET | /v1/projects/{slug}/forms/{id}/submissions | List submissions (?status=pending|confirmed|spam). |
GET | /v1/projects/{slug}/lists | List lists with member counts. |
POST | /v1/projects/{slug}/lists | Create a list. |
GET PATCH DELETE | /v1/projects/{slug}/lists/{id} | Read, rename, or delete a list (delete is 409 if a form still targets it). |
GET | /v1/projects/{slug}/lists/{id}/members | List a list’s members. |
POST | /v1/projects/{slug}/lists/{id}/members | Add a contact by external_id (fires list_entered). |
DELETE | /v1/projects/{slug}/lists/{id}/members | Remove a contact by external_id. |
Patch bodies are snake_case (target_list_ids, double_opt_in,
success_message, …). Publishing a form:
curl -X PATCH https://api.letter.app/v1/projects/acme/forms/frm_123 \
-H "Authorization: Bearer $LETTER_PAT" \
-H "Content-Type: application/json" \
-d '{ "status": "published" }'