# Contacts

> One record per person across your waitlist, changelog subscribers, feedback and paying customers from Stripe or Lemon Squeezy.

The same person joins your waitlist, subscribes to your changelog, sends feedback and later pays you. Contacts shows them as one record.

It is not a CRM. There are no deals, pipelines, tasks or email campaigns. It is a view over data Launch already holds, plus what you record on a person: an optional stage and note, and [details and consents](#details-and-consents).

## How people are matched

By email, after normalisation: lowercase, `+alias` removed, and dots ignored on Gmail. So `Me+launch@gmail.com` on the waitlist and `me@gmail.com` in Stripe are one contact. Two genuinely different addresses are two contacts; Launch does not guess.

| Source | Matched by |
|---|---|
| Waitlist signups | Email |
| Changelog subscribers | Email |
| Stripe customers ([connect Stripe](/guides/revenue-stripe)) | Email |
| Lemon Squeezy customers ([connect Lemon Squeezy](/guides/revenue-lemonsqueezy)) | Email |
| Feedback | The optional `email` on the submission, or your `user_id` (see below) |
| Notifications | Your `user_id` |

Feedback and notifications usually carry only your own `user_id`. If your Stripe customers have that id in metadata, name the metadata key when connecting Stripe and Launch links user id → customer → email.

Contacts works without Stripe; you just see no payment data. Stripe customers who never used a Launch form appear with `source: "stripe"`, Lemon Squeezy customers with `source: "lemonsqueezy"`. `stripe_status` is the payment status from whichever provider it is; the name is older than Lemon Squeezy support. A Stripe customer without an email cannot be matched and is not a contact, though they still count in revenue.

## List

```bash
curl "https://api.nordva.dev/v1/contacts?paying=true&has_feedback=true" \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY"
```

| Filter | Values |
|---|---|
| `paying` | `true` / `false`: has an active or past-due subscription |
| `has_feedback` | `true` / `false` |
| `status` | Waitlist status: `waiting`, `invited`, `unsubscribed` |
| `source` | A `utm_source` or referrer hostname, or `direct`, `referral`, `stripe` |
| `stage` | `potential`, `in_talks`, `customer`, `recurring`, `not_a_fit`, `inactive` |
| `q` | Part of an email address |
| `consent` | People who have granted this consent now, such as `email_marketing`; `waitlist` and `changelog` are built in |
| `detail` | `name` for people who have that detail, or `name=value` for that value (any case), such as `stage=building` |
| `limit`, `cursor` | Cursor pagination; `meta.next_cursor` and `meta.has_more` |

Each contact has `id`, `email`, `first_seen_at`, `source`, `waitlist_status`, `referral_count`, `subscribed`, `feedback_count`, `stripe_status` (`none`, `customer`, `trialing`, `active`, `past_due`, `canceled`), `plan`, `mrr_minor`, `currency`, `stage`, `details` (name to value), `consents` (the purposes granted now) and `user_ids` (your own user ids known for the person, from `identify()`, feedback or a paying customer's metadata: what [a notification to them](/guides/notifications/#send-to-a-group) reaches).

`GET /v1/contacts/{id}` adds the waitlist signup with its first-touch source, every subscription, lifetime paid per currency, the latest five feedback submissions with summaries, notifications sent to that user id and read (broadcasts to everyone are not counted per person), the note, and `detail_records` and `consent_records`: each detail and consent with its source and when it was set.

## Stage and note

```bash
curl -X PATCH https://api.nordva.dev/v1/contacts/ct_9f2c... \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "stage": "in_talks", "note": "Wants SSO before rolling out to the team." }'
```

Send `null` to clear either. A note replaces the previous one. The same call also takes `details` and `consents`, below.

## Details and consents

A **detail** is something your site learned about a person, as a name and a value: `building` = `saas`, `company_size` = `2-10`. A **consent** is a permission the person gave, by purpose: `email_marketing` granted, or withdrawn later. Both live on the contact, so you can list, filter and export by them. Goals and events are for what visitors do in general; a detail is about one person.

### From a form, with no code

On a form that already has `data-nordva-identify` (see [web analytics](/guides/web-analytics#an-email-in-a-form)), mark the fields you want kept:

```html
<form data-nordva-identify action="/signup" method="post">
  <input type="email" name="email" required>

  <select name="building" data-nordva-detail="building">
    <option value="saas">A SaaS product</option>
    <option value="mobile_app">A mobile app</option>
    <option value="agency">Client work</option>
  </select>

  <label>
    <input type="checkbox" name="news" data-nordva-consent="email_marketing">
    Email me product news and offers
  </label>

  <button>Sign up</button>
</form>
```

When the form is submitted:

- A field with `data-nordva-detail="name"` is sent as the detail `name` with the field's value. A radio button or checkbox counts only when it is checked; an empty field is skipped.
- A ticked checkbox with `data-nordva-consent="purpose"` records that consent as granted. An unticked box records nothing: it does not withdraw a consent the person gave before.
- Nothing else in the form is read.

A submitted form is sent whether or not the visit may carry ids: the person gave you the address by submitting it. Without ids (in the "Ask first" modes before anyone accepts, or with Global Privacy Control) it goes without a visitor id, so the person, their details and consents are kept but their visits are not linked to them. A `nordva('identify', …)` call from your own code still waits for ids.

### From your code

In the browser:

```js
nordva('identify', {
  email: 'ada@example.com',
  details: { building: 'saas', company_size: '2-10' },
  consents: { email_marketing: true },
});
```

From your server, the same fields go in the body of `POST /v1/analytics/identify`, or on an existing contact with `PATCH /v1/contacts/{id}`:

```bash
curl -X PATCH https://api.nordva.dev/v1/contacts/ct_9f2c... \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "details": { "company_size": "11-50", "building": null }, "consents": { "email_marketing": false } }'
```

To add someone your server knows but who has no visit (an account made in an app, an import), leave out `visitor_id`:

```bash
curl -X POST https://api.nordva.dev/v1/analytics/identify \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "user_42", "email": "pat@example.com", "details": { "world": "world3" } }'
```

A later `identify` that carries only a `user_id` reaches the person you identified earlier with that user id and an email. Details and consents need a person with an email; without one they are dropped.

### Rules

| | |
|---|---|
| Names | Lowercase letters, digits and `_`, starting with a letter, up to 40 characters. Used for both detail names and consent purposes |
| Detail values | Text, a number or true/false, stored as text, up to 200 characters. `null` removes the detail |
| Consents | `true` grants, `false` withdraws (from a browser only `true` is recorded, since anyone can type any address there). Each keeps `granted_at`, `withdrawn_at` and its source: `browser`, `api` or `dashboard` |
| Per call | At most 20 details and consents together |
| Per person | At most 50 details. A new one beyond that is skipped; an existing one is always updated |
| Built in | `waitlist` (on the waitlist and not unsubscribed) and `changelog` (subscribed to the changelog). You can filter by them but never set them |

In the API, a call that breaks a rule is refused with the reason. From the browser, a name that breaks a rule is dropped, a value longer than 200 characters is cut short, and the rest of the identify still goes through, so one bad field cannot lose the person.

`GET /v1/contacts/fields` lists the detail names and consent purposes in use, with how many people have each and the most common values. Check it before inventing a name, so the same thing is not recorded under two.

### Consent is yours to record honestly

Launch stores what your site sends; it cannot see what your form said or what the person agreed to. Record a consent only for what the person actually agreed to, with a box they ticked themselves (never ticked in advance), and word the box for one purpose. Record a withdrawal when they withdraw, for example from your own unsubscribe link. Whether a consent is valid for your emails under the law that applies to you is your responsibility, not Launch's.

### Export a list

`GET /v1/contacts/export` takes the same filters as the list and returns a CSV link for your email tool, valid for 15 minutes:

```bash
curl "https://api.nordva.dev/v1/contacts/export?consent=email_marketing&detail=building=saas" \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY"
```

The answer has `download_url`, `expires_at`, `row_count` and `truncated` (true past 10,000 rows). The link needs no key, so treat it as a secret. The CSV has one row per person: email, first seen, source, stage, the granted consents, waitlist status, changelog subscription, feedback count, Stripe status, plan, MRR and currency, then one column per detail, named `detail_<name>`. Cells that start like a spreadsheet formula are escaped. Exporting needs Indie or higher.

## Plans

| | Free | Indie | Builder |
|---|---|---|---|
| Who is listed | Waitlist people, first 100 | Everyone | Everyone |
| Stripe status, plan, MRR | For the people listed | Yes | Yes |
| Stage and note | Yes | Yes | Yes |
| Details, consents and their filters | For the people listed | Yes | Yes |
| CSV export | No | Yes | Yes |

## Deleting people

A contact never outlives the person's data. When you delete a signup in the dashboard, disconnect Stripe or Lemon Squeezy, or a customer is deleted in Stripe, a contact that no longer exists in any source is removed together with its note, details and consents.

## From an MCP client

"Which of my waitlist signups are paying now?", "What has jane@example.com asked for?", "Mark ada@example.com as in talks.", "Export everyone who agreed to marketing email." The assistant uses `list_contacts`, `get_contact`, `update_contact`, `list_contact_fields` and `export_contacts`.

Source: https://docs.nordva.dev/guides/contacts/
