# Provisioning a form

> Create a live form endpoint with a single POST, anonymously or with a workspace key.

Provisioning is how a form comes to life. One `POST /v1/forms` returns a live endpoint that starts accepting submissions immediately. There is no signup, and the same call works from a coding agent over MCP or from a raw `curl`.

## The request

Only `schema` is required. Everything else is optional.

```bash
curl -X POST https://api.lanes.sh/v1/forms \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": ["you@company.com"],
    "allowed_origins": ["example.com"],
    "schema": [
      {"name": "email", "type": "email", "required": true},
      {"name": "message", "type": "textarea"}
    ]
  }'
```

| Field | Type | Notes |
| --- | --- | --- |
| `schema` | array | Required. One or more fields. Each has `name`, `type`, optional `required` and `max_length`. |
| `recipients` | array | Addresses submissions forward to (max 5). The first is where a claim link is sent; each must be verified before forwarding reaches it. |
| `allowed_origins` | array | Browser origins allowed to submit, for example `["https://example.com"]`. Empty means no browser origin is accepted. |
| `name` | string | A label for the form in the dashboard. |
| `workflow` | array | Delivery actions (`email`, `store`, `webhook`). Defaults to email plus Lanes storage. |
| `forward_email` / `store_submissions` | boolean | Shorthand toggles when you do not want to pass a full `workflow`. |
| `metadata` | object | Arbitrary string key-value pairs stored with the form. |

Field types are `text`, `email`, `textarea`, `number`, `checkbox`, and `hidden`.

## What you get back

A create returns `201`; see [Idempotency](#idempotency) for the one case that returns `200`.

```json
{
  "form_id": "e5b1c0de-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
  "endpoint_url": "https://api.lanes.sh/v1/f/e5b1c0de-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
  "claim_url": null,
  "state": "unclaimed",
  "claim_email_sent_to": "you@company.com",
  "expires_at": "2026-07-13T09:00:00Z",
  "limits": { "unclaimed_max_submissions": 25 },
  "workflow": [
    { "type": "email", "enabled": true, "to": ["you@company.com"] },
    { "type": "store", "enabled": true, "destination": "lanes" },
    { "type": "webhook", "enabled": false, "url": null }
  ],
  "forward_email": true,
  "store_submissions": true,
  "idempotent_replay": false
}
```

Point your site at `endpoint_url` and you are collecting. See [Integrating a form](/docs/forms/integrating).

## Anonymous versus keyed

How you authenticate the create call decides the form's starting state.

### Anonymous (no auth)

The form is born `unclaimed`. It collects right away but holds everything until someone claims it.

- With one or more `recipients`, a claim link is emailed to the first one. `claim_email_sent_to` is set and `claim_url` is `null`.
- Without any `recipients`, no email is sent. Instead the response returns a single-use `claim_url` for you to hand to the form's owner.

### Keyed (workspace API key)

Send a workspace key and the form is born `claimed` directly into that key's workspace. No claim email is sent, no claim step is needed, and `claim_url`, `claim_email_sent_to`, and `expires_at` are all `null`.

```bash
curl -X POST https://api.lanes.sh/v1/forms \
  -H "Authorization: Bearer lfk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": ["you@company.com"],
    "allowed_origins": ["example.com"],
    "schema": [
      {"name": "email", "type": "email", "required": true},
      {"name": "message", "type": "textarea"}
    ]
  }'
```

See [API keys](/docs/forms/api-keys) to create one.

## Limits while unclaimed

An unclaimed form is a holding pen, not a dead end:

- It stores up to **25 submissions** (`limits.unclaimed_max_submissions`).
- It **forwards nothing**. Email delivery turns on only when the form is claimed.
- It **expires after 7 days** (`expires_at`) if never claimed, moving to the `frozen` state.

**Claim to keep collecting**

Claiming lifts these limits, releases every held submission into the dashboard, and turns on forwarding. See [Claiming a form](/docs/forms/claiming).

## Idempotency

Every create makes a new form. Two identical calls give you two independent forms, each with its own claim link: the same schema is not the same form.

To make a retry safe, send an `Idempotency-Key` header with a value you generate per call, and reuse that value if you retry:

```bash
curl -X POST https://api.lanes.sh/v1/forms \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0" \
  -d '{"schema":[{"name":"email","type":"email","required":true}]}'
```

Repeating a call with the same key within 24 hours replays the original form and returns `200` with `idempotent_replay: true` instead of creating a second one. A replay never re-issues the claim link (`claim_url` is `null`, and no claim email is re-sent), so keep the link from the original response. Keys are scoped to the caller, so yours never collides with anyone else's. Keyed (`lfk_`) creates always create a new form.

## Anti-abuse

The anonymous path is rate limited and gated so it cannot be used to spam inboxes:

| Guard | Limit | Error |
| --- | --- | --- |
| Disposable email addresses | rejected | `422 disposable_email` |
| Forms per IP per 24h | 20 | rate limited |
| Forms per email domain per 24h | 100 | rate limited |
| Pending unclaimed form for the same email | one at a time | `429 pending_claim_exists` |
| Claim emails per address per 24h | 5 | rate limited |

Keyed creates skip these gates, since the workspace key already establishes trust.

## Form states

A form moves through a small state machine:

- `unclaimed` to `claimed`: someone claims the form.
- `unclaimed` to `frozen`: 7 days pass with no claim. The endpoint then returns `410`.
- `frozen` to `claimed`: claiming from a reminder link restores a frozen form.
- `claimed` to `deleted`: the owner deletes it. The endpoint returns `410`.
- `frozen` or `deleted` to `purged`: 30 days later the data is hard-deleted.

Next: [Submitting data](/docs/forms/submitting) and [Integrating a form](/docs/forms/integrating).
