DocumentationContacts, lists and consentMarkdown
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:
- Unsubscribing always works, from any source.
- 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" gets409for that contact. Fresh consent lifts the suppression when the status issubscribed; apendingsign-up does not (confirmation mails come with the e-mail module). - 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). Withsubscribe.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. - 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). 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):
{
"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) 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 |