Contacts
A contact is an email address in one organization's audience, plus arbitrary custom properties. Group contacts with segments.
A contact has no name field
There is no name column. Store one as a custom property if you want it — see Property definitions below.
The contact object
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"email": "someone@example.com",
"properties": { "plan": "pro", "signed_up_at": "2026-01-14" },
"segment_ids": ["…"],
"unsubscribed": false,
"unsubscribed_at": null,
"unsubscribed_segment_ids": [],
"created_at": "2026-01-14T09:12:00+00:00",
"updated_at": "2026-01-14T09:12:00+00:00"
}segment_ids lists the segments the contact was added to by hand. A segment whose filter the contact matches is not listed here; ask the segment instead.
unsubscribed, unsubscribed_at and unsubscribed_segment_ids are derived and read-only. They come from the suppression list, joined by email address, not from a column on the contact. unsubscribed means the address opted out of all email; unsubscribed_segment_ids names the segments whose broadcasts it opted out of. See The suppression list.
Create a contact
POST /v1/contactscurl -X POST http://api.localhost/v1/contacts \
-H "X-API-Key: $NOTIFYZR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "someone@example.com",
"properties": { "plan": "pro", "signed_up_at": "2026-01-14" },
"segment_ids": ["3f2504e0-4f89-41d3-9a0c-0305e82c3301"]
}'| Field | Required | Notes |
|---|---|---|
email | yes | Normalised to lower case. Unique per organization. |
properties | no | An object. Keys with a declared type are validated against it. |
segment_ids | no | Segments to add the contact to by hand. Ids of other organizations' segments are ignored. |
A duplicate address returns 422 notifyzr.contact.error.email_exists.
Emits contact.created.
List, get, update, delete
GET /v1/contacts
GET /v1/contacts/{id}
PATCH /v1/contacts/{id}
DELETE /v1/contacts/{id}GET /v1/contacts is paginated and takes:
| Query | Values |
|---|---|
page, per_page | Default 25, max 100 |
search | Matches the email address |
unsubscribed | true / 1, or false / 0, for the opt-out from all email. Anything else is treated as absent — meaning both. |
PATCH accepts email, properties and segment_ids, all optional. A submitted segment_ids replaces the contact's manual memberships. It does not accept unsubscribed; use the endpoints below.
Deleting a contact does not lift its suppression — the record is keyed by address and outlives the contact on purpose.
Emits contact.updated and contact.deleted.
Subscription state
POST /v1/contacts/{id}/unsubscribe[?segment_id={segment}]
DELETE /v1/contacts/{id}/unsubscribe[?segment_id={segment}]Suppress the contact's address, or lift it. Without segment_id this is the opt-out from all email. With it, only that segment's broadcasts are affected, and an unknown segment returns 404 notifyzr.segment.error.not_found. Both emit only on a real state change — contact.unsubscribed and contact.resubscribed — so a repeated call is a silent no-op rather than a storm of duplicate webhooks. Both events carry segment_id, which is null for the opt-out from all email.
The suppression list
GET /v1/unsubscribes
DELETE /v1/unsubscribes/{id}Every opt-out this organization holds, in both scopes. It is a list in its own right rather than a view over contacts, because most entries never had a contact row — /v1/send takes free-form recipients.
Paginated:
{
"unsubscribes": [{
"id": "…",
"email": "someone@example.com",
"contact_id": null,
"source": "page",
"segment_id": null,
"segment_name": null,
"created_at": "…"
}],
"pagination": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 }
}source is page (the hosted preference page), api or list_unsubscribe. segment_id is null for an opt-out from all email.
Filter with segment_id: a segment id lists that segment's opt-outs, and global lists only opt-outs from all email. search matches the address.
Where suppression is enforced — and where it deliberately is not — is in the Unsubscribes guide.
Property definitions
POST /v1/contact-properties
GET /v1/contact-properties
PATCH /v1/contact-properties/{id}
DELETE /v1/contact-properties/{id}A declared property has a type, which is what lets a segment offer the right operators for it.
{
"id": "…",
"key": "plan",
"label": "Plan",
"type": "text",
"default_value": null,
"sort_order": 0,
"created_at": "…",
"updated_at": "…"
}type is text, number, boolean or date. key is lowercase letters, digits and underscores, starting with a letter.
You can store undeclared keys on a contact freely. What you cannot do is filter on them.
Retyping a property
Change a declared type after data exists and contacts whose stored value no longer matches simply drop out of a filter, rather than the whole segment erroring. That is deliberate — see Segments.
Bulk import
POST /v1/contact-imports
GET /v1/contact-imports
GET /v1/contact-imports/{id}The split here is worth understanding: the API parses and validates the whole file synchronously; only the write is queued. A malformed row comes back in the 202 response rather than being discovered by polling.
Two accepted content types
# multipart — the conventional upload
curl -X POST http://api.localhost/v1/contact-imports \
-H "X-API-Key: $NOTIFYZR_API_KEY" \
-F "file=@contacts.csv" \
-F 'mapping={"Email":"email","Plan":"plan"}' \
-F "on_conflict=upsert"# JSON — the shape a browser-side client can send
curl -X POST http://api.localhost/v1/contact-imports \
-H "X-API-Key: $NOTIFYZR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"csv": "Email,Plan\nada@example.com,pro\n",
"filename": "contacts.csv",
"mapping": { "Email": "email", "Plan": "plan" },
"on_conflict": "upsert"
}'| Field | Required | Notes |
|---|---|---|
file / csv | yes | The CSV. First line must be a header. |
mapping | yes | CSV column → property key. Exactly one column must map to "email". Unmapped columns are ignored. In multipart it is a JSON string, since multipart carries no nested values. |
on_conflict | no | skip (default) leaves an existing contact alone; upsert updates it. |
filename | no | Label only. |
Anything else returns 415 notifyzr.contact_import.error.unsupported_media_type.
Limits
10 MB and 50,000 rows. Errors are reported as a bounded sample of up to 100.
The response
{
"id": "…",
"filename": "contacts.csv",
"status": "pending",
"on_conflict": "upsert",
"mapping": { "Email": "email", "Plan": "plan" },
"total_rows": 10000,
"imported_rows": 0,
"updated_rows": 0,
"skipped_rows": 0,
"failed_rows": 3,
"errors": [{ "row": 42, "message": "invalid email address" }],
"last_error": null,
"created_at": "…",
"started_at": null,
"finished_at": null
}status describes only the write — pending, processing, completed or failed. A row that reached the table already parsed cleanly.
What is a row error and what is a file error
| Result | |
|---|---|
| A malformed row | Dropped, counted in failed_rows, sampled in errors. The other 9,999 still import. |
| A malformed file — unknown column, no email mapping, unreadable | The whole upload is refused. |
Duplicate addresses within one file are de-duplicated, last row wins, and reported in errors so the behaviour is visible.
Running out of contact quota partway through is not an error: the contacts that fit are real, the rest are counted in skipped_rows, and the import completes. Emits contact_import.completed.