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.
{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
400with 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
| Field | Type | Semantics |
|---|---|---|
emailDomainIn | string[] | Contact email's domain is in this list (OR within field). |
emailDomainNotIn | string[] | Negation. |
emailContains | string | Case-insensitive substring on full email. |
nameContains | string | Case-insensitive substring on name. |
metadataEq | {key: value} | Each key → metadata->>key = value. |
metadataContains | {key: substring} | Each key → metadata->>key ILIKE %v%. |
createdAfter | ISO datetime | Inclusive (>=). |
createdBefore | ISO datetime | Exclusive (<). |
inListIds | uuid[] | Member of any of these lists. |
notInListIds | uuid[] | 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:
- Confirmation copy with the list name.
- A radio choice: This list (default) or All emails from this sender.
- 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.