> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stage.systems/llms.txt
> Use this file to discover all available pages before exploring further.

# Signups setup

> Give a site lists: a signup component that names a list, one public endpoint, and the Performance card that reads and exports it. The table is the platform's; the component is the site's.

A **signup** is list membership: an email filed under a list identifier the site declares. It is deliberately not the enquiry pipeline. Enquiries and form responses become a *person* (deduped by email, company-enriched, shown in the Inbox); signups are rows in their own table, one per `(list, email)`, and an address can sit on several lists. What the owner sees is in [Guides: Signups](/analytics/signups).

## The pieces

| Piece              | Where                  | Notes                                                                                                                                                   |
| ------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Signup`           | `components/global/`   | The component: an email input, a submit, the done state. The template ships a plain one. Restyle it; keep the `data-signup-*` hooks the script binds to |
| `POST /api/signup` | The shared API         | Token-gated, validated, one row per `(org, list, email)`. Repeat signups merge                                                                          |
| `signups` table    | The platform database  | Members read and export under row-level security. The API is the only writer                                                                            |
| Signups card       | `/admin` → Performance | Appears once the org has a signup. One list at a time; CSV / JSON / plain export                                                                        |

## Declaring a list

The list is a prop on the component. Lowercase slug, letters, digits, `-` and `_`, up to 40 characters.

```astro theme={null}
---
import Signup from "../components/global/Signup.astro";
---
<Signup list="waitlist" placeholder="you@company.com" button="Join the waitlist" done="You're on the list." />
```

Nothing is registered anywhere. The first signup under a new slug creates the list, and the Performance card grows a selector when a site has more than one.

## Extra details

Any input named `fields.<key>` is sent alongside the email and stored on the row under that key. They become columns in the CSV export and a column on the card.

```html theme={null}
<input type="text" name="fields.name" placeholder="Your name" />
<input type="text" name="fields.company" placeholder="Company" />
```

A second POST for the same list and email merges its `fields` into the existing row. That is how a two-step signup works: email first, then a follow-up question whose answer is posted as `fields.<key>`.

## The request

If you build your own markup instead of the template's component, this is the contract. The component reads `window.__apiUrl` and `window.__siteDomain`, which the base layout already sets for the contact overlay. It fetches a token from `/api/form-token` when the visitor first focuses the form and waits until the token is at least two seconds old before posting: the API rejects younger tokens, so a token fetched at submit time and spent at once always fails.

```json theme={null}
{
  "list": "waitlist",
  "email": "a@b.co",
  "fields": { "name": "Ann" },
  "token": "<form token>",
  "_hp": "",
  "anonymous_id": "<s cookie>",
  "ref": "<stage_ref cookie>", "first": "<stage_first cookie>",
  "vid": "<stage_vid cookie>", "abx": "<stage_abx cookie>", "consent": "<consent cookie>"
}
```

| Response                                 | Meaning                                                                                         |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `200 { "success": true, "first": true }` | A new row. `first: false` means the address was already on the list and its details were merged |
| `400 { "error": "…" }`                   | Invalid list slug or email. The message is written for the visitor                              |
| `403`                                    | Bad or foreign token, or the site's domain is not registered                                    |
| `429`                                    | Token already used, or a second post within two seconds from the same IP address                |

The same checks as the contact form apply: syntax, a disposable-domain blocklist, and a live mail-server lookup. A honeypot field (`_hp`) that arrives filled is accepted and discarded.

## As a conversion

The first signup to a list emits a custom event keyed `signup:<list>`. It appears in the funnel wizard and the A/B goal picker under **Custom event**, so a list can be a funnel's last step or a test's goal with no further wiring. Merges do not emit again.

## Database

`migrations/signups.sql`, additive. Columns: `list`, `email`, `fields` (JSON), `source` (the page path), `status` (`active` / `unsubscribed`), `consent_at`, `unsubscribed_at`, the last-touch attribution columns shared with `persons` and `form_submissions`, `first_touch`. Unique on `(org_id, list, email)`. The function `signup_lists(p_org_id)` returns each list with its total and active counts; the card opens with it, so the counts always cover the whole list. The table pages through the list ten rows at a time, and an export reads the list's active members fresh when a format is picked. Exports are limited to the latest 50,000 rows: past that, the file holds the latest 50,000 and its name says so.

Deferred: double opt-in, a public unsubscribe link, deleting from the card.
