Contacts API
Manage contacts, contact lists, list members, tags, bulk operations and CSV import via the NorthRelay API.
Contacts API
The Contacts API stores recipient information, organizes contacts into lists, and manages tags for targeted campaigns. Every route on this page works with an API key (Authorization: Bearer nr_live_...) as well as from the dashboard.
To subscribe someone with consent evidence and double opt-in, or to unsubscribe them, use the Subscriptions API instead of writing contact status directly: it applies the opt-out rules, records consent and fires the contact.* and list.* webhooks.
Scopes
| Routes | Scope |
|---|---|
GET on any /contacts/** route, and GET /contacts/export | contacts:read |
POST, PATCH, PUT, DELETE on any /contacts/** route | contacts:write |
Response format
Contact routes (/contacts, /contacts/{id}, tags, bulk) use the standard envelope: { "success": true, "data": ... } on success and { "success": false, "error": { "code", "message", "fix_action", "docs_url" } } on failure. List routes (/contacts/lists/**) return { "data": ... }; their newer member endpoints also include success. Import returns the result object directly.
List Contacts
GET /contactsQuery Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 100 | Items per page (max 1000) |
status | string | -- | ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED or CLEANED |
source | string | -- | MANUAL, CSV_IMPORT, API, FORM or SYNC |
tag | string | -- | Only contacts with this tag |
search | string | -- | Matches email, first name or last name (case-insensitive) |
sortBy | string | createdAt | createdAt, email or subscribedAt |
sortOrder | string | desc | asc or desc |
To list the members of one list, use GET /contacts/lists/{id}/members.
Example Request
curl "https://app.northrelay.ca/api/v1/contacts?limit=10&search=alice" \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx"Example Response
{
"success": true,
"data": [
{
"id": "cmg7c0n1t0001qz8k2v5x9abc",
"email": "alice@example.com",
"firstName": "Alice",
"lastName": "Johnson",
"phone": null,
"metadata": { "company": "Acme Inc" },
"source": "API",
"status": "ACTIVE",
"subscribedAt": "2026-10-01T10:00:00.000Z",
"unsubscribedAt": null,
"confirmedAt": null,
"consentSource": "api",
"consentAt": "2026-10-01T10:00:00.000Z",
"createdAt": "2026-10-01T10:00:00.000Z",
"updatedAt": "2026-10-03T08:00:00.000Z",
"tags": [{ "id": "cmg7tag0000001", "contactId": "cmg7c0n1t0001qz8k2v5x9abc", "tag": "vip" }],
"customFields": [{ "id": "cmg7cf00000001", "contactId": "cmg7c0n1t0001qz8k2v5x9abc", "key": "plan", "value": "pro" }]
}
],
"meta": {
"page": 1,
"limit": 10,
"total_count": 1,
"has_more": false
}
}The example is abridged: contacts also carry userId, consentIp, consentUserAgent, consentText, engagement fields and confirmationFormId.
Get Contact
GET /contacts/{id}Returns the contact with its tags and customFields. Unknown ids and contacts of other accounts return 404 RESOURCE_NOT_FOUND.
curl https://app.northrelay.ca/api/v1/contacts/cmg7c0n1t0001qz8k2v5x9abc \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx"For a contact's lists, topics and consent evidence in one call, use GET /subscriptions/{email}.
Create Contact
POST /contactsRate limited to 60 requests per minute per client. For larger loads use bulk create or CSV import.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Contact email address (lowercased) |
firstName | string | No | Max 100 characters |
lastName | string | No | Max 100 characters |
phone | string | No | E.164 format, for example +15145551234 |
metadata | object | No | Arbitrary JSON, max 4 KB |
source | string | No | MANUAL, CSV_IMPORT, API, FORM or SYNC. Defaults to API. |
status | string | No | ACTIVE (default), UNSUBSCRIBED, BOUNCED, COMPLAINED or CLEANED |
tags | string[] | No | Up to 50 tags; lowercase letters, digits, - and _, max 50 characters each |
customFields | object | No | String key/value pairs |
This route does not add the contact to lists, record consent evidence or fire webhooks. To do that in one call, use POST /subscriptions.
Example Request
curl -X POST https://app.northrelay.ca/api/v1/contacts \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"email": "bob@example.com",
"firstName": "Bob",
"lastName": "Smith",
"tags": ["eu-west", "trial"],
"customFields": { "plan": "trial" },
"metadata": { "company": "Example Corp" }
}'Returns 201 with the created contact (including tags and customFields) in data.
Errors
| Code | Status | Fix |
|---|---|---|
VALIDATION_ERROR | 400 | Correct the field named in error.context.field |
DUPLICATE_CONTACT | 409 | A contact with this email already exists. Update it with PATCH /contacts/{id} |
RATE_LIMIT_EXCEEDED | 429 | Wait error.context.retry_after seconds |
Update Contact
PATCH /contacts/{id}Only the fields you send are changed. Accepts email, firstName, lastName, phone, metadata, source and status. Use the tag routes for tags.
Status changes must follow the allowed transitions:
| From | Allowed to |
|---|---|
PENDING | ACTIVE, UNSUBSCRIBED, CLEANED |
ACTIVE | UNSUBSCRIBED, BOUNCED, COMPLAINED, CLEANED |
UNSUBSCRIBED | ACTIVE, PENDING |
BOUNCED | ACTIVE |
COMPLAINED, CLEANED | none (terminal) |
An invalid transition returns 400 INVALID_STATUS_TRANSITION. Changing status here only updates the contact; it does not add or lift suppressions or fire webhooks. To unsubscribe or resubscribe someone, use the Subscriptions API.
curl -X PATCH https://app.northrelay.ca/api/v1/contacts/cmg7c0n1t0001qz8k2v5x9abc \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "firstName": "Robert", "metadata": { "company": "Example Corp", "seats": 12 } }'Delete Contact
DELETE /contacts/{id}Permanently deletes the contact, its tags, custom fields and list memberships. Suppressions are kept, so a deleted address that had unsubscribed stays suppressed.
curl -X DELETE https://app.northrelay.ca/api/v1/contacts/cmg7c0n1t0001qz8k2v5x9abc \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx"{ "success": true, "data": { "message": "Contact deleted successfully" } }Tags
Add tags to a contact
POST /contacts/{id}/tagscurl -X POST https://app.northrelay.ca/api/v1/contacts/cmg7c0n1t0001qz8k2v5x9abc/tags \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "tags": ["vip", "beta-tester"] }'tags takes 1–50 tags (lowercase letters, digits, - and _). Existing tags are kept. Returns the contact with its tags. A contact holds at most 50 tags; exceeding that returns 400 TAG_LIMIT_EXCEEDED.
Remove a tag from a contact
DELETE /contacts/{id}/tags/{tag}curl -X DELETE https://app.northrelay.ca/api/v1/contacts/cmg7c0n1t0001qz8k2v5x9abc/tags/beta-tester \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx"List all tags in your account
GET /contacts/tagsReturns { "data": [{ "name", "count", "color", "definitionId" }] } with the number of contacts carrying each tag.
Bulk Create and Delete
Bulk create
POST /contacts/bulk| Field | Type | Required | Description |
|---|---|---|---|
contacts | object[] | Yes | 1–1,000 contacts, each with the Create Contact fields |
skipDuplicates | boolean | No | Default true: existing emails are counted in skipped. With false they are reported in errors. |
curl -X POST https://app.northrelay.ca/api/v1/contacts/bulk \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "email": "one@example.com", "firstName": "One" },
{ "email": "two@example.com", "tags": ["trial"] }
]
}'{
"success": true,
"data": { "created": 2, "skipped": 0, "errors": [] }
}errors is a list of { "email", "error" }. Bulk create does not update existing contacts or add list memberships.
Bulk delete
DELETE /contacts/bulkcurl -X DELETE https://app.northrelay.ca/api/v1/contacts/bulk \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "contactIds": ["cmg7c0n1t0001qz8k2v5x9abc", "cmg7c0n1t0004qz8kzzzz1111"] }'{ "success": true, "data": { "deleted": 2, "requested": 2 } }contactIds takes 1–1,000 ids. Ids that are not yours are ignored (deleted < requested).
CSV Import
POST /contacts/importImports a CSV file using multipart form data. New addresses are created as ACTIVE contacts with source CSV_IMPORT and consent source import; existing addresses are counted in skipped and not updated.
| Field | Type | Required | Description |
|---|---|---|---|
file | CSV file | Yes | .csv, up to 2 MiB and 1,000 data rows plus the header row |
mappings | JSON string | Yes | Array of { "csvColumn": "<header>", "field": "email" | "firstName" | "lastName" | "phone" | "skip" }. csvColumn must match a header exactly. Exactly one column must map to email. |
listId | string | No | Add every imported contact, and every existing contact found in the file, to this list |
tags | string | No | Comma-separated tags applied to newly created contacts (up to 20) |
Headers must be unique. Quoted commas, escaped quotes and multiline fields are supported. Maximum 100 columns and 4,096 characters per field; multipart overhead is capped at 64 KiB. Split larger imports into separate requests.
Example Request
curl -X POST https://app.northrelay.ca/api/v1/contacts/import \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
-F "file=@subscribers.csv" \
-F 'mappings=[{"csvColumn":"Email","field":"email"},{"csvColumn":"First Name","field":"firstName"},{"csvColumn":"Company","field":"skip"}]' \
-F "listId=cmg7l1st00002qz8kfe4w1xyz" \
-F "tags=imported-2026-10,newsletter"Example Response
{
"imported": 182,
"skipped": 14,
"addedToList": 196,
"errors": ["Line 37: Invalid email format - jane@@example.com"]
}| Field | Description |
|---|---|
imported | New contacts created |
skipped | Rows whose address already existed |
addedToList | Contacts (new or existing) added to listId by this import; 0 without listId |
errors | One message per rejected row |
Errors
Import errors use a simpler body, { "error": "<message>" }:
| Status | When |
|---|---|
| 400 | No file, not a .csv, invalid mappings, no email mapping, mapped email column missing, duplicate headers, malformed CSV, or row/column/field limits exceeded |
| 404 | listId is not one of your lists |
| 413 | File over 2 MiB or request too large |
Importing never clears suppressions: an address that bounced, complained or unsubscribed stays suppressed, and campaigns skip it. Only import addresses that agreed to receive your mail; import does not fire contact.subscribed or list.member_added webhooks.
Contact Lists
Lists are named audiences for campaigns. A list can be tagged with a topic (suppressionGroupId): recipients who opt out of that topic leave the list and are skipped when you send to it. See Suppressions: topics.
List all contact lists
GET /contacts/lists| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 20 | Items per page (max 100) |
type | string | -- | STATIC, DYNAMIC or ALL |
isArchived | boolean | false | Show archived lists instead |
search | string | -- | Matches name or description |
{
"data": [
{
"id": "cmg7l1st00002qz8kfe4w1xyz",
"name": "Monthly newsletter",
"description": "Everyone who signed up on the website",
"type": "STATIC",
"contactCount": 1542,
"isArchived": false,
"createdAt": "2026-09-15T10:00:00.000Z",
"updatedAt": "2026-10-05T12:00:00.000Z",
"lastSyncedAt": null
}
],
"pagination": { "page": 1, "limit": 20, "total": 3, "totalPages": 1 }
}contactCount counts current members (removed memberships are excluded).
Create a contact list
POST /contacts/lists| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1–255 characters |
description | string | No | Max 1,000 characters |
type | string | No | STATIC (default) or DYNAMIC |
segmentRules | object | No | Rules for a dynamic list |
suppressionGroupId | string | null | No | Topic this list belongs to |
trackingEnabled | boolean | No | Open/click tracking for campaigns to this list (default true) |
curl -X POST https://app.northrelay.ca/api/v1/contacts/lists \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "name": "Product updates", "suppressionGroupId": "cmg7t0p1c0003qz8k7h2d4pqr" }'Returns 201 with { "data": { ...list } }.
Get a contact list
GET /contacts/lists/{id}Returns { "data": { ...list } } including suppressionGroup ({ id, name, publicLabel } or null) and _count.members.
Update a contact list
PATCH /contacts/lists/{id}Accepts name, description, segmentRules, isArchived, suppressionGroupId (set null to untag) and trackingEnabled.
Archive a contact list
DELETE /contacts/lists/{id}Archives the list (isArchived: true). Contacts and memberships are kept. Returns { "success": true }.
List Members
List members
GET /contacts/lists/{id}/members| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 50 | Items per page (max 100) |
filter | string | all | all, active (no suppression flags) or suppressed |
{
"data": [
{
"id": "cmg7m3mb00005qz8k1a2b3c4d",
"contactId": "cmg7c0n1t0001qz8k2v5x9abc",
"contact": {
"id": "cmg7c0n1t0001qz8k2v5x9abc",
"email": "jane@example.com",
"firstName": "Jane",
"lastName": null,
"status": "ACTIVE",
"subscribedAt": "2026-10-06T14:02:11.000Z",
"createdAt": "2026-10-06T14:02:11.000Z"
},
"addedAt": "2026-10-06T14:02:11.000Z",
"removedAt": null,
"suppressionStatuses": []
}
],
"counts": { "total": 1543, "active": 1530, "suppressed": 13 },
"pagination": { "page": 1, "limit": 50, "total": 1543, "totalPages": 31 }
}Campaigns go only to current members whose contact is not PENDING. suppressionStatuses lists every reason the member will not receive mail: REMOVED_FROM_LIST (unsubscribed from this list), UNSUBSCRIBED, BOUNCED, COMPLAINED, CLEANED, MANUALLY_SUPPRESSED, CATEGORY_SUPPRESSED (opted out of the list's topic).
Add members
POST /contacts/lists/{id}/membersAdd existing contacts by id and/or email. With createMissing: true, unknown emails are created as ACTIVE contacts (source API) first.
| Field | Type | Required | Description |
|---|---|---|---|
contactIds | string[] | One of contactIds / emails | Up to 1,000 contact ids |
emails | string[] | One of contactIds / emails | Up to 1,000 addresses |
createMissing | boolean | No | Create contacts for unknown emails. Addresses that unsubscribed, bounced, complained or are blocked are not created; they are reported in blocked. |
curl -X POST https://app.northrelay.ca/api/v1/contacts/lists/cmg7l1st00002qz8kfe4w1xyz/members \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "emails": ["jane@example.com", "new@example.com", "gone@example.com"], "createMissing": true }'{
"success": true,
"data": {
"added": 2,
"alreadyMembers": 0,
"notFound": [],
"blocked": [{ "email": "gone@example.com", "reason": "RECIPIENT_UNSUBSCRIBED" }]
},
"added": 2,
"skipped": 0,
"message": "Added 2 contacts to list"
}| Field | Description |
|---|---|
data.added | Contacts added (or restored after an earlier removal) |
data.alreadyMembers | Contacts that were already current members |
data.notFound | Ids not in your account, and unknown emails when createMissing is not set |
data.blocked | { email, reason } for unknown emails that could not be created; reason is a Subscriptions error code such as RECIPIENT_UNSUBSCRIBED, RECIPIENT_SUPPRESSED or FREE_TIER_CONTACT_LIMIT |
added, skipped, message | Kept for older clients; skipped equals data.alreadyMembers |
Existing contacts are added as they are; send-time suppression still skips any that unsubscribed, bounced or complained. A list.member_added webhook fires for each added member. A list that is not yours returns 404 LIST_NOT_FOUND.
Remove members
DELETE /contacts/lists/{id}/membersRemove by body { "contactIds": [...], "emails": [...] }, or a single member with ?contactId= or ?email=.
curl -X DELETE "https://app.northrelay.ca/api/v1/contacts/lists/cmg7l1st00002qz8kfe4w1xyz/members?email=jane%40example.com" \
-H "Authorization: Bearer nr_live_xxxxxxxxxxxx"{ "success": true, "data": { "removed": 1 } }This is the list owner removing someone: the membership is deleted and a list.member_removed webhook fires (source: "API"). It is not an unsubscribe; the contact can be added again. To record that a recipient asked to leave, use POST /subscriptions/unsubscribe with scope: "list".
| Code | Status | Fix |
|---|---|---|
VALIDATION_ERROR | 400 | Send contactIds or emails, or ?contactId= / ?email= |
NOT_A_MEMBER | 404 | None of the contacts is in the list. Check with GET /contacts/lists/{id}/members |
LIST_NOT_FOUND | 404 | Check the list id with GET /contacts/lists |
SDK
TypeScript (@northrelay/sdk 1.9.0):
// Lists (tag a list with a topic via suppressionGroupId)
const { data: list } = await client.contacts.createList({ name: 'Product updates', suppressionGroupId: topicId });
// Members by email, creating unknown contacts
const added = await client.contacts.addListMembers(list.id, { emails: ['jane@example.com'], createMissing: true });
console.log(added.data.added, added.data.blocked);
// Mailable members only
const page = await client.contacts.getListMembers(list.id, { filter: 'active', limit: 100 });
// Owner removal (not an unsubscribe)
await client.contacts.removeListMembers(list.id, { emails: ['jane@example.com'] });
// CSV import into the list
const imported = await client.contacts.importCsv(csvText, {
mappings: [{ csvColumn: 'Email', field: 'email' }],
listId: list.id,
tags: ['imported'],
});Python (northrelay 1.9.0, async). List methods take topic_id for suppressionGroupId:
lst = await client.contacts.create_list("Product updates", topic_id=topic_id)
added = await client.contacts.add_to_list(lst.id, emails=["jane@example.com"], create_missing=True)
print(added.added, added.already_members, added.blocked)
page = await client.contacts.list_members(lst.id, filter="active", limit=100)
await client.contacts.remove_from_list(lst.id, emails=["jane@example.com"])
imported = await client.contacts.import_csv(
"subscribers.csv",
mappings=[{"csvColumn": "Email", "field": "email"}],
list_id=lst.id,
tags=["imported"],
)MCP tools: list_contact_lists, get_contact_list, create_contact_list, update_contact_list, delete_contact_list, add_list_members, remove_list_members, list_list_members.
Contact Object Reference
| Field | Type | Description |
|---|---|---|
id | string | Unique contact identifier |
email | string | Contact email address (lowercase) |
firstName | string | null | First name |
lastName | string | null | Last name |
phone | string | null | Phone number |
metadata | object | null | Arbitrary metadata |
source | string | MANUAL, CSV_IMPORT, API, FORM or SYNC |
status | string | PENDING, ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED or CLEANED |
subscribedAt | string | ISO 8601 time the contact (re)subscribed |
unsubscribedAt | string | null | ISO 8601 time of the last global unsubscribe |
confirmedAt | string | null | ISO 8601 time a double opt-in was confirmed |
consentSource | string | null | How consent was obtained: api, form (signup form), import, preference_center, ... |
consentAt | string | null | When consent was recorded |
consentIp | string | null | Subscriber IP (supplied by you, or the submitting browser for signup forms) |
consentUserAgent | string | null | Subscriber user agent (as above) |
consentText | string | null | Wording the subscriber agreed to; signup forms record Signup form: <form name> |
tags | object[] | { id, contactId, tag } |
customFields | object[] | { id, contactId, key, value } |
createdAt | string | ISO 8601 creation timestamp |
updatedAt | string | ISO 8601 last update timestamp |