
# Form Availability

Registration opens Monday. The workshop seats forty. The survey closes at the end of the quarter. Each of those is a rule about _when_ a form accepts answers, and each one otherwise becomes a calendar reminder to go and edit the config by hand.

`availability` states the rule once and lets the server enforce it:

```yaml
forms:
  - id: 1
    name: workshop-signup
    title: Spring Workshop
    submitTo: { table: registrations }
    availability:
      opensAt: '2026-03-01T09:00:00Z'
      closesAt: '2026-03-20T23:59:59Z'
      maxSubmissions: 40
    fields:
      - { kind: table-field, column: email, required: true }
```

## Availability Properties

Every property is optional. Omit the block and the form is open, forever, with no cap.

| Property         | Description                                                               | Default                   |
| ---------------- | ------------------------------------------------------------------------- | ------------------------- |
| `opensAt`        | ISO 8601 timestamp at which the form starts accepting submissions.        | Open immediately.         |
| `closesAt`       | ISO 8601 timestamp at which it stops.                                     | Never closes.             |
| `maxSubmissions` | Positive integer cap. The submission after the cap is refused.            | Unlimited.                |
| `closedPage`     | Custom page shown while the form is closed, for any of the three reasons. | Title + boilerplate copy. |

When both timestamps are set, `opensAt` must be strictly before `closesAt`. A window that closes before it opens accepts nothing and is almost always a typo, so it fails when the config is decoded, naming the form and both timestamps.

## What a Closed Form Answers

Opening a closed form is not an error — the visitor gets a page explaining the situation. Posting to one is refused with a structured body so a programmatic client can tell the three cases apart.

| State               | `GET /forms/{name}` | `POST` status | Error body                                                            |
| ------------------- | ------------------- | ------------- | --------------------------------------------------------------------- |
| Before `opensAt`    | Closed page         | `403`         | `{ error: 'form not yet open', opensAt }`                             |
| After `closesAt`    | Closed page         | `403`         | `{ error: 'form closed', closedAt }`                                  |
| At `maxSubmissions` | Form still renders  | `403`         | `{ error: 'submission limit reached', maxSubmissions, currentCount }` |

A refused submission writes nothing — no ledger row, no table row, no automation run.

## The Closed Page

Left unconfigured, the closed page shows the form's own title and a sentence explaining that it is not yet open (quoting `opensAt`) or no longer accepting answers. `closedPage` replaces that with your own copy and, optionally, a way forward:

```yaml
availability:
  closesAt: '2026-03-20T23:59:59Z'
  closedPage:
    title: Registration has closed
    message: Spring places are full. The autumn cohort opens in July.
    cta:
      label: Join the waiting list
      href: /waiting-list
```

| Property    | Description                                      | Default                             |
| ----------- | ------------------------------------------------ | ----------------------------------- |
| `title`     | Page heading.                                    | The form's `title`.                 |
| `message`   | Body copy.                                       | Default explanation for the reason. |
| `cta.label` | Link text. Requires `cta.href`.                  | No link.                            |
| `cta.href`  | Link target. Requires `cta.label`.               | No link.                            |
| `type`      | Reserved discriminator; only `page` is accepted. | `page`                              |

The same page serves all three closed states, so write copy that reads sensibly whether the form has not opened yet or has filled up.

:::callout
**Only real submissions consume a cap slot.** A submission rejected as spam — a tripped honeypot, a rate-limit miss — and one that failed downstream never count toward `maxSubmissions`. A bot cannot exhaust your forty workshop seats in a burst, and a failed write does not quietly cost a genuine registrant their place. The cap is also enforced atomically, so concurrent submissions racing for the last slots cannot overshoot it.
:::

## Related Pages

- [Anti-Spam & Attribution](/en/docs/form-anti-spam) — the spam classification that is excluded from the cap.
- [Access Control](/en/docs/form-access) — the other reason a form may refuse a submitter.
- [Submissions](/en/docs/form-submissions) — the ledger status a refused submission does _not_ create.
- [Forms Overview](/en/docs/forms-overview) — where `availability` sits in the full form schema.
