Skip to content

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 ​

json
{
  "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/contacts
bash
curl -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"]
  }'
FieldRequiredNotes
emailyesNormalised to lower case. Unique per organization.
propertiesnoAn object. Keys with a declared type are validated against it.
segment_idsnoSegments 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:

QueryValues
page, per_pageDefault 25, max 100
searchMatches the email address
unsubscribedtrue / 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:

json
{
  "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.

json
{
  "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 ​

bash
# 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"
bash
# 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"
  }'
FieldRequiredNotes
file / csvyesThe CSV. First line must be a header.
mappingyesCSV 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_conflictnoskip (default) leaves an existing contact alone; upsert updates it.
filenamenoLabel 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 ​

json
{
  "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 rowDropped, counted in failed_rows, sampled in errors. The other 9,999 still import.
A malformed file — unknown column, no email mapping, unreadableThe 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.

Released under the Apache 2.0 License.