# Forms

Take contact forms, sign-ups and feedback without writing a backend. Mark a form in your HTML, deploy, and Deplane receives its submissions, keeps them, and tells you about them.

## Add a form

Add `data-auto-form` and a `name` to a `<form>`:

```html
<form name="contact" data-auto-form data-auto-form-honeypot="company" data-auto-form-thanks="/thanks.html">
  <p hidden><input name="company"></p>
  <input name="email" type="email" required>
  <textarea name="message"></textarea>
  <button>Send</button>
</form>
```

Deploy. Deplane reads the `.html` files in your project, finds every marked form with its fields, and sets where each one posts: it writes `action="/__auto_forms/…"` and `method="POST"` into the form in the build. You never set the action yourself, and none of your app's own paths is ever taken over. Netlify's `netlify` and `data-netlify="true"` attributes work too, so forms from a Netlify site need no change; a Netlify form's `action` becomes its thank-you page.

After a submission, the visitor goes to the page in `data-auto-form-thanks` (a page of your site). Without it, they see a short "Thanks, we got it." page. With `fetch` and `Accept: application/json`, the answer is `{"ok": true}`.

Only fields that are in the deployed form are kept. File fields are not supported: their files are dropped.

## Forms built by JavaScript

Deplane reads only HTML files, never JSX, Vue or Svelte. For a form your JavaScript builds, declare it in `deplane.toml`:

```toml
[[forms]]
name = "signup"
fields = ["email", "plan"]
thanks = "/welcome"   # optional
```

After the deploy, `deplane forms` (or the dashboard's **Forms** tab) shows the path the form posts to. Post to it:

```js
await fetch("/__auto_forms/3f9c…", {       // the path deplane forms shows
  method: "POST",
  headers: { "Accept": "application/json" },
  body: new URLSearchParams({ email, plan }),
});
```

The path stays the same across deploys.

## Versions of a form

Each deploy uses its own version of each form. Rolling back brings the earlier version back too. A field is never deleted: fields the deployed version doesn't have are left out of the inbox, the CLI and the emails, and come back if a later version has them again. The CSV export has every field ever collected.

## Read submissions

In the dashboard, open the app's **Forms** tab. From the CLI:

```sh
deplane forms                              # forms, counts, and where each posts
deplane forms submissions contact          # the newest 100
deplane forms submissions contact --status spam
deplane forms export contact > contact.csv # all of them, every field, as CSV
deplane forms delete contact ID            # one; without ID, all of them
```

Submissions are kept until you delete them. There is no limit on how many a form takes; a form that holds 100,000 stops taking new ones until you delete some, and you get an alert. Spam is deleted after 7 days.

Submissions are your visitors' personal data: they are encrypted at rest with a key of your team's own, and AI tools (MCP, agent tokens) can never read them: they can list a team's forms and counts (`list_forms`), nothing more. Members read submissions; only owners delete them or change notifications and CAPTCHA.

## Notifications

```sh
deplane forms notify contact --email you@example.com,teammate@example.com
pbpaste | deplane forms notify contact --slack -
pbpaste | deplane forms notify contact --webhook -
deplane forms notify contact --slack off
```

- **Email** goes only to members of your team, as a digest of what came in: up to 8 a day on Free (every 3 hours), up to twice an hour on paid plans. When nothing came in, no email is sent.
- **Slack** gets a short message for each submission: the form, its first fields, and a link to the inbox.
- **Webhooks** get each submission as JSON: `{"form", "app", "submissions": [{"data", "created_at"}], "more"}`. The response shows a signing secret once; check that `X-Deplane-Signature` is `sha256=` followed by the HMAC-SHA256 of the body with that secret. Failing deliveries are retried, and after a day of failures you get an alert.
- **Busy forms are collated:** while a message for a form is being sent or retried, new submissions wait and go out together in the next one. Slack says how many came in and shows the newest; a webhook gets up to the 100 newest in `submissions`, with `more` counting the rest. Every submission is in the inbox either way.

Spam never notifies.

## Spam

- **Honeypot:** a hidden field (`data-auto-form-honeypot="NAME"`, or Netlify's `netlify-honeypot`). People leave it empty; bots fill it in. Those submissions go to spam.
- **Rate limits:** 10 a minute from one visitor, 600 an hour per form.
- **CAPTCHA:** Cloudflare Turnstile, per form.

### CAPTCHA

On your app's own address (`<app>.deplane.app`), turn it on and add the widget with Deplane's site key, shown in the dashboard's Forms tab:

```sh
deplane forms captcha contact on
```

```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<!-- inside the form -->
<div class="cf-turnstile" data-sitekey="SITE_KEY"></div>
```

On a custom domain, Deplane's key does not work (Cloudflare limits each key to a few hostnames). Create a free Turnstile widget for your domain at Cloudflare, use its site key in the widget, and give Deplane its secret key:

```sh
pbpaste | deplane forms captcha contact on --secret -
```

`deplane forms captcha contact on` again keeps your key; `--secret off` goes back to Deplane's.

If Cloudflare cannot be reached, the submission is kept in spam rather than lost.

Turnstile sends your visitors' browser data to Cloudflare. Mention it in your site's privacy notice.
