Contacts
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.
How people are matched
Section titled “How people are matched”By email, after normalisation: lowercase, +alias removed, and dots ignored on Gmail. So [email protected] on the waitlist and [email protected] in Stripe are one contact. Two genuinely different addresses are two contacts; Launch does not guess.
| Source | Matched by |
|---|---|
| Waitlist signups | |
| Changelog subscribers | |
| Stripe customers (connect Stripe) | |
| Lemon Squeezy customers (connect Lemon Squeezy) | |
| 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.
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 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
Section titled “Stage and note”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
Section titled “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
Section titled “From a form, with no code”On a form that already has data-nordva-identify (see web analytics), mark the fields you want kept:
<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 detailnamewith 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
Section titled “From your code”In the browser:
nordva('identify', { 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}:
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:
curl -X POST https://api.nordva.dev/v1/analytics/identify \ -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \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.
| 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
Section titled “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
Section titled “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:
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.
| 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
Section titled “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
Section titled “From an MCP client”“Which of my waitlist signups are paying now?”, “What has [email protected] asked for?”, “Mark [email protected] 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.