Contacts & lists

Contacts are the source of truth for who can receive email. Lists group them for newsletters and automations. Suppressions ensure unsubscribers stay unsubscribed everywhere.

The data model

  • Contact — one row per (product, lower(email)). Has a name, optional metadata blob (any JSON), timestamps. Email is the natural key.
  • List — a named group of contacts. A contact can be in any number of lists.
  • List membership — join row between contact and list. Removing it unsubscribes the contact from that list only.
  • Suppression — a global "do not email" record at the product level. Beats every list membership.

Import contacts

Two paths, same result.

CSV upload

POST /api/v1/contacts/import   (multipart/form-data)
file=<your.csv>
targetListId=<uuid>            // optional — add imported contacts to this list
hasHeader=true                  // default
onConflict=skip                 // skip|update; default skip

Header rules: email is required; name is recognized; any other column becomes metadata.<column>. BOM is tolerated. Sample CSV:

email,name,company,plan
alice@example.com,Alice Smith,Acme,pro
bob@example.com,Bob Jones,Globex,free
carol@example.com,Carol Lee,,trial

(The import modal has a Download sample CSV link with this exact content.)

JSON body

POST /api/v1/contacts/import
{
  "rows": [
    { "email": "alice@example.com", "name": "Alice", "metadata": { "plan": "pro" } },
    { "email": "bob@example.com",   "name": "Bob" }
  ],
  "targetListSlug": "newsletter-monthly",
  "onConflict": "update"
}

Skip vs update

  • onConflict: "skip" (default) — existing contacts are reused as-is. Name + metadata in the CSV row are ignored.
  • onConflict: "update" — existing contacts have name and metadata replaced wholesale from the CSV row. Metadata fields not in the CSV are dropped. Email is never updated.
Update replaces metadata wholesale. If the row has only {plan: "pro"} but the existing contact has {plan: "free", company: "Acme"}, the result is {plan: "pro"}company is dropped. Carry every field forward in each row, or use skip mode.

Response

{
  "totalRows":   5000,
  "created":     3200,    // new contacts
  "updated":     0,       // contacts whose name+metadata were replaced (update mode only)
  "skipped":     1750,    // contacts that already existed (skip mode)
  "suppressed":  12,      // rows whose email is in the suppression list
  "addedToList": 4938,    // memberships created on the target list
  "invalid":     50,
  "errors": [
    { "row": 12, "error": "Invalid email: 'not-an-email'" }
    // up to 100 errors returned
  ]
}

Limits

  • 10,000 rows per request, 5 MB CSV.
  • Sync only — request returns when the import finishes. Plan around ~30 seconds for 10k rows.
  • Malformed CSV returns 400 with the parser's actual error (row number + reason), not a generic 500.

Lists

Create a manual list

POST /api/v1/lists
{ "slug": "vip-customers", "name": "VIP customers" }

Add contacts to a list

POST /api/v1/lists/{id}/members
{ "contactIds": ["contact_01...", "contact_02..."] }

Build a list from a filter

Instead of curating manually, ask Mailazy to materialize a list from a filter expression:

POST /api/v1/lists/from-filter
{
  "name": "Business contacts not yet onboarded",
  "slug": "business-not-onboarded",
  "filter": {
    "emailDomainIn":    ["acme.com", "globex.com"],
    "emailDomainNotIn": ["gmail.com"],
    "metadataEq":       { "plan": "pro" },
    "createdAfter":     "2026-01-01T00:00:00Z",
    "notInListIds":     ["onboarded-list-uuid"]
  }
}

Filter DSL

FieldTypeSemantics
emailDomainInstring[]Contact email's domain is in this list (OR within field).
emailDomainNotInstring[]Negation.
emailContainsstringCase-insensitive substring on full email.
nameContainsstringCase-insensitive substring on name.
metadataEq{key: value}Each key → metadata->>key = value.
metadataContains{key: substring}Each key → metadata->>key ILIKE %v%.
createdAfterISO datetimeInclusive (>=).
createdBeforeISO datetimeExclusive (<).
inListIdsuuid[]Member of any of these lists.
notInListIdsuuid[]Not a member of any of these.

All non-empty conditions combine with AND. Empty / null conditions are no-ops. Unknown top-level keys are rejected (forward-compat guardrail).

Preview before materializing

GET /api/v1/lists/filter-preview?filter=<urlencoded JSON>
→ { "count": 1234, "sample": [...50 contacts...] }

The dashboard filter builder calls this on a 400ms debounce so you see the count update as you tweak conditions.

Materialization limit

Filters cap at 50,000 contacts per materialization. Over-cap requests return 422 with the actual matched count so you can narrow the filter.

Filters are materialized, not dynamic — they become a real list with real members. If you want the audience to recompute over time, re-run the filter on a schedule (or wait for the dynamic-segments follow-up).

Suppressions

A suppression is a global "do not email" record on a single (product, lower(email)). The drainer skips suppressed contacts at fire time across newsletters and automations alike.

Reasons

  • unsubscribed_all — user clicked "Unsubscribe from all" on the unsubscribe page.
  • bounced — recipient hard-bounced (reserved; manual ingestion only in v1).
  • admin — operator added it via API or dashboard.

API

GET    /api/v1/suppressions?email=&page=&limit=
POST   /api/v1/suppressions       { "email": "alice@example.com", "reason": "admin" }
DELETE /api/v1/suppressions/{id}

Delete the suppression to re-enable a contact. List memberships are not restored automatically — re-add them via the lists API or a fresh import.

Unsubscribe page

Every marketing email's footer (the {{> unsubscribe-footer}} partial) links to a confirmation page hosted at /api/v1/unsubscribe. The page shows:

  1. Confirmation copy with the list name.
  2. A radio choice: This list (default) or All emails from this sender.
  3. Submit button.

This list removes the membership; All additionally writes a contact_suppressions row with reason unsubscribed_all.

RFC 8058 one-click unsubscribes (the native Gmail/Yahoo button) always default to per-list — mail clients can't communicate scope, and a one-click global wipe would surprise users who only meant to leave one mailing list.

Related