NorthRelayNorthRelay
API Reference

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

RoutesScope
GET on any /contacts/** route, and GET /contacts/exportcontacts:read
POST, PATCH, PUT, DELETE on any /contacts/** routecontacts: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 /contacts

Query Parameters

ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger100Items per page (max 1000)
statusstring--ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED or CLEANED
sourcestring--MANUAL, CSV_IMPORT, API, FORM or SYNC
tagstring--Only contacts with this tag
searchstring--Matches email, first name or last name (case-insensitive)
sortBystringcreatedAtcreatedAt, email or subscribedAt
sortOrderstringdescasc 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 /contacts

Rate limited to 60 requests per minute per client. For larger loads use bulk create or CSV import.

Request Body

FieldTypeRequiredDescription
emailstringYesContact email address (lowercased)
firstNamestringNoMax 100 characters
lastNamestringNoMax 100 characters
phonestringNoE.164 format, for example +15145551234
metadataobjectNoArbitrary JSON, max 4 KB
sourcestringNoMANUAL, CSV_IMPORT, API, FORM or SYNC. Defaults to API.
statusstringNoACTIVE (default), UNSUBSCRIBED, BOUNCED, COMPLAINED or CLEANED
tagsstring[]NoUp to 50 tags; lowercase letters, digits, - and _, max 50 characters each
customFieldsobjectNoString 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

CodeStatusFix
VALIDATION_ERROR400Correct the field named in error.context.field
DUPLICATE_CONTACT409A contact with this email already exists. Update it with PATCH /contacts/{id}
RATE_LIMIT_EXCEEDED429Wait 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:

FromAllowed to
PENDINGACTIVE, UNSUBSCRIBED, CLEANED
ACTIVEUNSUBSCRIBED, BOUNCED, COMPLAINED, CLEANED
UNSUBSCRIBEDACTIVE, PENDING
BOUNCEDACTIVE
COMPLAINED, CLEANEDnone (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}/tags
curl -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/tags

Returns { "data": [{ "name", "count", "color", "definitionId" }] } with the number of contacts carrying each tag.


Bulk Create and Delete

Bulk create

POST /contacts/bulk
FieldTypeRequiredDescription
contactsobject[]Yes1–1,000 contacts, each with the Create Contact fields
skipDuplicatesbooleanNoDefault 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/bulk
curl -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/import

Imports 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.

FieldTypeRequiredDescription
fileCSV fileYes.csv, up to 2 MiB and 1,000 data rows plus the header row
mappingsJSON stringYesArray of { "csvColumn": "<header>", "field": "email" | "firstName" | "lastName" | "phone" | "skip" }. csvColumn must match a header exactly. Exactly one column must map to email.
listIdstringNoAdd every imported contact, and every existing contact found in the file, to this list
tagsstringNoComma-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"]
}
FieldDescription
importedNew contacts created
skippedRows whose address already existed
addedToListContacts (new or existing) added to listId by this import; 0 without listId
errorsOne message per rejected row

Errors

Import errors use a simpler body, { "error": "<message>" }:

StatusWhen
400No file, not a .csv, invalid mappings, no email mapping, mapped email column missing, duplicate headers, malformed CSV, or row/column/field limits exceeded
404listId is not one of your lists
413File 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
ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger20Items per page (max 100)
typestring--STATIC, DYNAMIC or ALL
isArchivedbooleanfalseShow archived lists instead
searchstring--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
FieldTypeRequiredDescription
namestringYes1–255 characters
descriptionstringNoMax 1,000 characters
typestringNoSTATIC (default) or DYNAMIC
segmentRulesobjectNoRules for a dynamic list
suppressionGroupIdstring | nullNoTopic this list belongs to
trackingEnabledbooleanNoOpen/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
ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger50Items per page (max 100)
filterstringallall, 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}/members

Add existing contacts by id and/or email. With createMissing: true, unknown emails are created as ACTIVE contacts (source API) first.

FieldTypeRequiredDescription
contactIdsstring[]One of contactIds / emailsUp to 1,000 contact ids
emailsstring[]One of contactIds / emailsUp to 1,000 addresses
createMissingbooleanNoCreate 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"
}
FieldDescription
data.addedContacts added (or restored after an earlier removal)
data.alreadyMembersContacts that were already current members
data.notFoundIds 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, messageKept 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}/members

Remove 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".

CodeStatusFix
VALIDATION_ERROR400Send contactIds or emails, or ?contactId= / ?email=
NOT_A_MEMBER404None of the contacts is in the list. Check with GET /contacts/lists/{id}/members
LIST_NOT_FOUND404Check 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

FieldTypeDescription
idstringUnique contact identifier
emailstringContact email address (lowercase)
firstNamestring | nullFirst name
lastNamestring | nullLast name
phonestring | nullPhone number
metadataobject | nullArbitrary metadata
sourcestringMANUAL, CSV_IMPORT, API, FORM or SYNC
statusstringPENDING, ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED or CLEANED
subscribedAtstringISO 8601 time the contact (re)subscribed
unsubscribedAtstring | nullISO 8601 time of the last global unsubscribe
confirmedAtstring | nullISO 8601 time a double opt-in was confirmed
consentSourcestring | nullHow consent was obtained: api, form (signup form), import, preference_center, ...
consentAtstring | nullWhen consent was recorded
consentIpstring | nullSubscriber IP (supplied by you, or the submitting browser for signup forms)
consentUserAgentstring | nullSubscriber user agent (as above)
consentTextstring | nullWording the subscriber agreed to; signup forms record Signup form: <form name>
tagsobject[]{ id, contactId, tag }
customFieldsobject[]{ id, contactId, key, value }
createdAtstringISO 8601 creation timestamp
updatedAtstringISO 8601 last update timestamp

On this page