# Contacts, lists and consent

Contacts are the people a tenant knows by e-mail or by its own id: customers, leads, newsletter
subscribers. They are the base for e-mail campaigns and, later, Snakkbar's CRM. Everything here is
reachable through the server API, so a tenant's own systems (a CMS, an ERP such as Business
Central, a CRM) keep Snakkbar in sync themselves. Snakkbar ships no connector for any of them.

| Thing | What it is |
| --- | --- |
| **Contact** | A person: e-mail (unique per tenant, lower case) and/or `externalId` (your id, unique per tenant), name, phone, job title, language, company, up to 30 custom `attributes`. |
| **Company** | An organisation: name, `domain`, `orgNumber`, `externalId`, `attributes`. A contact belongs to at most one. |
| **List** | Something a contact subscribes to (a newsletter, product news). Up to 50 per tenant. |
| **Subscription** | A contact's status on a list: `subscribed`, `unsubscribed` or `pending` (waiting for a confirmation mail), with the **basis** for mailing them. |
| **Consent log** | Every subscription change, with source and proof. Append-only. |
| **Suppression** | An address nothing is sent to: `unsubscribed` (from everything), `bounced`, `complained` or `manual`. Kept by address, so it outlives an erased contact. |
| **Activity** | One entry on a contact's timeline: subscription changes (and later mails, clicks, site visits, chats) recorded by Snakkbar, plus whatever your systems log. |

In the panel (**Mailbar → Kontakter**, a list or «Alle kontakter»), people are added one at a time
(«Legg til kontakt», with the list and basis), imported from Excel (.xlsx, the first sheet) or CSV,
and exported as either. An export's first columns carry the import's own headings (E-post, Fornavn,
Etternavn, Firma, Telefon, Stilling, Ekstern ID), so it can be edited and imported again as it is;
a list's export adds each contact's status and basis on it. CSV is written with `;` and a byte
order mark, as Excel in Norway expects, and a field that starts like a formula gets a leading `'`.

**Moving from Mailchimp** (Innstillinger → Import → «Flytt fra Mailchimp», admins): paste a Mailchimp
API key (Profile → Extras → API keys), tick one or more audiences («Merk alle» / «Fjern alle»), pick
a list for each (an existing one, else a new list named after the audience), and map their fields
once (to contact fields or attributes). A single audience can also bring its groups and tags (each
to a list of its own, an attribute, or left out). Snakkbar then reads the audiences one after the
other over Mailchimp's Marketing API in the background:

- subscribed members go on their audience's list with the basis you choose and Mailchimp's own
  record as proof per person (when, from which IP, how they signed up);
- unsubscribed members are unsubscribed from that list only: an unsubscribe in Mailchimp is per
  audience, so the address stays open for the other lists;
- cleaned (bounced) addresses are blocked for every e-mail, so no later import or sync adds them back.

Someone in several audiences is one contact on several lists. Nothing is written to Mailchimp.
«Flytt flere publikum» moves more audiences later with the same key (the ones moved are marked).
«Hent endringer» reads every audience moved so far, each only for members changed since it was last
read (switch-over day); «Koble fra» forgets the key, which is kept encrypted until then.
Operator API: `GET /operator/mailchimp`, `POST /operator/mailchimp/connect` `{ apiKey }`,
`GET /operator/mailchimp/imports/:id/audiences`, `GET …/imports/:id/fields?audiences=a,b`,
`POST …/imports/:id/start` (**MailchimpMappingInput** `{ audiences: [{ id, list }], basis, fields,
interests?, tags? }`), `POST …/imports/:id/changes`, `DELETE …/imports/:id/key`.

A **visitor** (a browser on the site) is not a contact. A visitor your backend identifies with
`POST /server/visitor-tokens` and an `email` is linked to the contact with that address, so their
visits and chats can be shown on the contact.

## Consent rules

Every subscription has a `basis`:

| Basis | Use it when |
| --- | --- |
| `consent` | The person said yes (a form, a tick box, in writing). Put what they agreed to in `proof`. |
| `existing_customer` | They are a customer and the mail is about similar products, with an easy opt-out (markedsføringsloven § 15, third paragraph). |
| `legitimate_interest` | B2B: the mail concerns the recipient's role at a company. |

The legal judgement is the tenant's. Snakkbar stores the basis and the proof and enforces four
rules:

1. **Unsubscribing always works**, from any source.
2. **An unsubscribe wins over a sync.** Once someone unsubscribed, from that list or from
   everything (or was blocked by hand), subscribing them again needs `basis: "consent"`; a nightly
   sync that still says "customer" gets `409` for that contact. Fresh consent lifts the suppression
   when the status is `subscribed`; a `pending` sign-up does not (confirmation mails come with the
   e-mail module).
3. **An import never subscribes them again**, whatever basis it claims: a file (`/contacts/bulk`)
   or a Mailchimp move leaves someone who unsubscribed from the list, or from everything, as they
   are, and says so per row. Only the person can sign up again (or a single call with their fresh
   consent). With `subscribe.skipUnsubscribed`, an import also leaves out anyone who has
   unsubscribed from *any* list before (`subscriptionSkipped: "unsubscribed_before"`); the panel's
   import has it on by default.
4. **Bounced and complained addresses** cannot be subscribed again at all.

A change to the same status and basis is a no-op: a sync that sends the same data every night
logs nothing and fires no events.

## Authentication and scopes

All endpoints take a secret API key (`Authorization: Bearer sk_snakkbar_…`, see
[api.md](api.md#authentication)). Reading needs the `contacts:read` scope, anything that changes
data needs `contacts:write` (which includes reading). Give an integration only what it needs: a
newsletter sign-up form on the website needs `contacts:write` and nothing else.

## Referring to a contact or company

Paths take a `:ref`, one of:

| Form | Example |
| --- | --- |
| Snakkbar id | `/server/contacts/4f7c1f0e-6a0b-4c1e-9a55-2b7d1c0e9f11` |
| `ext:<externalId>` | `/server/contacts/ext:KT00123` |
| `email:<address>` (contacts only) | `/server/contacts/email:ola@firma.no` |

URL-encode the ref (`encodeURIComponent`) when it can contain `/`, `?`, `#` or `%`.

## Contacts

**Contact** (`packages/sdk/src/types.ts`):

```json
{
  "id": "4f7c1f0e-…", "externalId": "KT00123", "email": "ola@firma.no",
  "firstName": "Ola", "lastName": "Nordmann", "phone": "+47 900 00 000", "title": "Innkjøpssjef",
  "language": "no", "companyId": "9a1b…", "attributes": { "segment": "industri" },
  "subscriptions": [{ "listId": "c2d4…", "status": "subscribed", "basis": "legitimate_interest", "updatedAt": "…" }],
  "suppressed": null, "createdAt": "…", "updatedAt": "…"
}
```

Input fields (all optional, but a new contact needs `email` or `externalId`): `externalId`,
`email`, `firstName`, `lastName`, `phone`, `title`, `language`, `companyId` or `companyExternalId`
(`null` unlinks), `attributes`. A field left out is kept, `null` clears it, `attributes` are merged
(`null` removes a key).

| Method and path | Body / result |
| --- | --- |
| `GET /server/contacts?updatedSince=&cursor=&limit=&companyId=&q=` | → `{ contacts: Contact[], nextCursor }`, oldest change first. `q`: up to 5 words that must each be in the e-mail, first or last name (case-insensitive, ≤ 100 chars). |
| `POST /server/contacts` | a contact → `{ contact }`, `201` created or `200` updated. Matched by `externalId`, then e-mail. `409` when the `externalId` and the e-mail belong to different contacts, or the address belongs to a contact with another `externalId` (change that with `PATCH`). Sending what is already stored changes nothing, `updatedAt` included. |
| `POST /server/contacts/bulk` | `{ contacts: [≤ 500], subscribe?: { listId, basis, proof?, skipUnsubscribed? } }` → `{ results: [{ id, created, subscriptionSkipped?, subscriptionError? } \| { error: { code, message } }] }`, one per input, in order. One bad row never stops the others. |
| `GET /server/contacts/:ref` | → `{ contact }` |
| `PATCH /server/contacts/:ref` | any input fields → `{ contact }`. Can change `email` and `externalId`. |
| `DELETE /server/contacts/:ref` | Erases the contact with its subscriptions, consent log and timeline → `204` (also when unknown). A suppression of the address stays. Fires `contact.deleted`. |
| `GET /server/contacts/:ref/export` | Everything stored about the contact (right of access), as a JSON download. |
| `GET /server/contacts/:ref/consents` | → `{ consents: ConsentRecord[] }`, newest first. |
| `POST /server/contacts/:ref/subscriptions` | `{ listId, status, basis?, proof? }` → `{ contact, changed }`. `basis` is required unless unsubscribing. `409` under the consent rules. |
| `POST /server/contacts/:ref/unsubscribe-all` | `{ proof? }` → `{ contact }`. Unsubscribes from every list and suppresses the address. |
| `GET /server/contacts/:ref/activities?before=&limit=` | → `{ activities: ContactActivity[] }`, newest first; `before` is an activity id. |
| `POST /server/contacts/:ref/activities` | `{ type, title?, data?, occurredAt?, externalId? }` → `{ activity }` (`201`, or `200` for an `externalId` already logged). |

**Syncing.** Pull changes with `GET /server/contacts?updatedSince=<last run>` and follow
`nextCursor` until it is `null`. Push changes with `POST /server/contacts/bulk`, using your own id
as `externalId` so a changed e-mail address updates the right contact.

**Activities from your systems.** `type` is your own name (`order.created`, `quote.sent`,
`meeting.held`: lower case, digits, `.`, `-`, `_`, ≤ 60 chars), `title` a line for people,
`data` any JSON up to 8 kB. Send your own id as `externalId` and a retried push is stored once.
Activities you log have `source: "api"`; Snakkbar's own have `source: "snakkbar"`.

## Companies

**Company**: `id`, `externalId`, `name`, `domain` (lower case, no `www.`), `orgNumber`,
`attributes`, `createdAt`, `updatedAt`.

| Method and path | Body / result |
| --- | --- |
| `GET /server/companies?updatedSince=&cursor=&limit=` | → `{ companies, nextCursor }` |
| `POST /server/companies` | `{ name, externalId?, domain?, orgNumber?, attributes? }` → `{ company }`. Updates the company with that `externalId` if there is one (`200`), else creates (`201`). |
| `GET /server/companies/:ref` | → `{ company }` (`:ref` is an id or `ext:<externalId>`) |
| `PATCH /server/companies/:ref` | any of the fields → `{ company }` |
| `DELETE /server/companies/:ref` | → `204`. Its contacts stay, without a company. |

Contacts of a company: `GET /server/contacts?companyId=<id>`.

## Lists

**ContactList**: `id`, `name`, `description`, `public` (shown on the subscriber's preference
page), `doubleOptIn` (sign-ups from forms wait for a confirmation mail), `subscribed` (count),
`createdAt`, `archivedAt`.

| Method and path | Body / result |
| --- | --- |
| `GET /server/lists?archived=true` | → `{ lists }`. Archived lists only with `archived=true`. |
| `POST /server/lists` | `{ name, description?, public?, doubleOptIn? }` → `{ list }` (`201`) |
| `GET /server/lists/:id` | → `{ list }` |
| `PATCH /server/lists/:id` | any of the fields, or `archived: boolean` → `{ list }` |
| `DELETE /server/lists/:id` | Archives the list → `204`. Members and consent history are kept. |
| `GET /server/lists/:id/members?status=subscribed&cursor=&limit=` | → `{ contacts, nextCursor }` |
| `POST /server/lists/:id/members` | `{ contacts: [≤ 500 refs], status, basis?, proof? }` → `{ results: [{ contactId, changed } \| { error }] }`, one per ref, in order. |

## Suppressions

| Method and path | Body / result |
| --- | --- |
| `GET /server/suppressions?since=&before=&limit=` | → `{ suppressions: Suppression[] }`, newest first. |
| `POST /server/suppressions` | `{ email, reason?: "manual" \| "unsubscribed", detail? }` → `{ email, suppressed: true }`. `unsubscribed` for a known contact is a full unsubscribe, logged as consent. |
| `DELETE /server/suppressions/:email` | Lifts a `manual` block → `204`. Other reasons: `409` (an unsubscribe lifts on fresh consent, bounces and complaints never). |

## Webhooks

Subscribe to `contact.subscribed`, `contact.unsubscribed` and `contact.deleted`
([webhooks.md](webhooks.md#events)) to mirror subscription changes into your own systems, for
example to mark a contact as "no marketing" in your ERP when they unsubscribe from a mail.
Subscription events carry the contact as it is now (`contact`). Changes your own key made are
included, so apply them idempotently. Plain field changes made through the API fire no events:
your system made them and already knows.

## Limits

| What | Limit |
| --- | --- |
| Bulk requests (`/contacts/bulk`, `/lists/:id/members`) | 30 per minute per tenant, 500 items each |
| Page size (`limit`) | 1–200, default 50 |
| Custom attributes | 30 per contact or company; keys `A–Z a–z 0–9 _ . -` up to 40 chars; values string (≤ 200), number or boolean |
| Lists | 50 active per tenant |
