Hopp til innholdet

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.

ThingWhat it is
ContactA 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.
CompanyAn organisation: name, domain, orgNumber, externalId, attributes. A contact belongs to at most one.
ListSomething a contact subscribes to (a newsletter, product news). Up to 50 per tenant.
SubscriptionA contact's status on a list: subscribed, unsubscribed or pending (waiting for a confirmation mail), with the basis for mailing them.
Consent logEvery subscription change, with source and proof. Append-only.
SuppressionAn address nothing is sent to: unsubscribed (from everything), bounced, complained or manual. Kept by address, so it outlives an erased contact.
ActivityOne 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.

Every subscription has a basis:

BasisUse it when
consentThe person said yes (a form, a tick box, in writing). Put what they agreed to in proof.
existing_customerThey are a customer and the mail is about similar products, with an easy opt-out (markedsføringsloven § 15, third paragraph).
legitimate_interestB2B: 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). 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:

FormExample
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 pathBody / 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/contactsa 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/:refany input fields → { contact }. Can change email and externalId.
DELETE /server/contacts/:refErases 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/exportEverything 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 pathBody / 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/:refany 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 pathBody / 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/:idany of the fields, or archived: boolean → { list }
DELETE /server/lists/:idArchives 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 pathBody / 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/:emailLifts 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

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