NorthRelayNorthRelay
API Reference

Subscriptions API

Subscribe and unsubscribe recipients, run double opt-in, read and update preferences across lists and topics.

Subscriptions API

The Subscriptions API is the one place to change who receives which mail. Use it from your own application to build newsletter signups, a preference page inside your product, or server-side opt-outs. Every call applies the same rules as NorthRelay's hosted forms, unsubscribe links and preference centre, and fires the same webhooks.

For a walkthrough of how the pieces fit together, see the Mailing Lists guide.

Concepts

TermWhat it isManaged with
ListAn audience you send campaigns to. A contact is either a member or not./contacts/lists
TopicA category of mail a recipient can opt out of (for example "Product updates"). Stored as a suppression group. A member row in a topic means the address opted out of that topic./suppression-groups
Global unsubscribeThe recipient wants no marketing mail at all. The contact status becomes UNSUBSCRIBED and an account-level suppression with reason Unsubscribe is added.This API

Rules the API enforces for you:

  • Subscribing to a topic means removing the recipient's opt-out for it. Opting out of a topic also removes the recipient from every list tagged with that topic.
  • Unsubscribing from a list keeps the membership history (the membership is marked removed). Removing a member as the list owner deletes it (see list members).
  • Someone who unsubscribed is never re-added silently. You must pass "resubscribe": true, and you should only do so after fresh consent.
  • Bounce, complaint and manual suppressions are never lifted by a subscribe call.
  • Contacts with status COMPLAINED or CLEANED cannot be resubscribed.

Authentication and scopes

All endpoints in the first part of this page take an API key: Authorization: Bearer nr_live_.... Restricted keys need these scopes:

EndpointScope
GET /subscriptions/{email}contacts:read
POST /subscriptions, POST /subscriptions/unsubscribe, PATCH /subscriptions/{email}, POST /subscriptions/{email}/resend-confirmationcontacts:write

A key without the scope receives 403 SCOPE_INSUFFICIENT; a missing or invalid key receives 401.

The public endpoints for unsubscribe pages you host yourself (POST /unsubscribe, POST /unsubscribe/one-click) take the signed token from the unsubscribe link instead of an API key. See Unsubscribe links.

Response format

Successful responses use the standard envelope:

{ "success": true, "data": { } }

Errors include the fix:

{
  "success": false,
  "error": {
    "code": "RECIPIENT_UNSUBSCRIBED",
    "message": "jane@example.com has unsubscribed.",
    "fix_action": "The recipient unsubscribed. Pass \"resubscribe\": true only when they have given fresh consent (for example by submitting a form again), ideally with \"doubleOptIn\": true.",
    "docs_url": "https://docs.northrelay.ca/docs/api-reference/subscriptions"
  }
}

Validation errors use code VALIDATION_ERROR and name the offending field in error.context.field.


Subscribe

Create or update a contact, add it to lists, opt it into topics and record consent. With doubleOptIn: true NorthRelay emails a confirmation link and the contact stays PENDING until the recipient clicks it.

POST /subscriptions

Returns 201 when the contact was created, 200 when it already existed. Repeating the same call is safe: nothing is added twice.

Request body

Unknown fields are rejected.

FieldTypeRequiredDescription
emailstringYesRecipient address. Trimmed and lowercased. Max 254 characters.
firstNamestringNoMax 100 characters
lastNamestringNoMax 100 characters
phonestringNoMax 40 characters
listIdsstring[]NoLists to add the contact to (max 50)
topicIdsstring[]NoTopics to opt the contact into, i.e. remove any opt-out (max 50)
tagsstring[]NoTags to add (max 50, each 1–50 characters)
customFieldsobjectNoString key/value pairs stored as custom fields (keys max 100, values max 1,000 characters)
metadataobjectNoArbitrary JSON stored on the contact (replaces existing metadata)
doubleOptInbooleanNoSend a confirmation email and keep the contact PENDING until confirmed. Ignored for a contact that is already ACTIVE.
confirmationTemplateIdstringNoOne of your templates to use for the confirmation email. It can use {{confirm_url}}, {{first_name}}, {{last_name}} and {{email}}. Defaults to NorthRelay's confirmation template with your brand theme.
redirectUrlstringNohttps:// URL the recipient is sent to after confirming. Without it they see a NorthRelay confirmation page.
resubscribebooleanNoRequired to re-add someone who previously unsubscribed. Only use it with fresh consent.
consentobjectNoConsent evidence: { ip?, userAgent?, text? }. See below.

NorthRelay stores the consent source (api for this endpoint), the time, and whatever you pass in consent:

FieldTypeDescription
consent.ipstringThe subscriber's IP address. Pass it from your server: NorthRelay only sees your server's address. Max 64 characters.
consent.userAgentstringThe subscriber's browser user agent. Max 1,024 characters.
consent.textstringThe exact wording the subscriber agreed to (for example the checkbox label). Max 2,000 characters.

Together these are the record of express consent that CASL (Canada) and GDPR (EU) expect you to be able to produce. Consent is recorded when the contact is created, when it is resubscribed, or when it had no consent record yet; a repeat subscribe does not overwrite earlier evidence. Read it back with Get subscription status.

Example request

curl -X POST https://app.northrelay.ca/api/v1/subscriptions \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "firstName": "Jane",
    "listIds": ["cmg7l1st00002qz8kfe4w1xyz"],
    "topicIds": ["cmg7t0p1c0003qz8k7h2d4pqr"],
    "doubleOptIn": true,
    "redirectUrl": "https://yourapp.com/newsletter/confirmed",
    "consent": {
      "ip": "203.0.113.24",
      "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6)",
      "text": "Yes, send me the monthly newsletter. I can unsubscribe at any time."
    }
  }'

Example response (201)

{
  "success": true,
  "data": {
    "contactId": "cmg7c0n1t0001qz8k2v5x9abc",
    "email": "jane@example.com",
    "status": "PENDING",
    "created": true,
    "listIds": ["cmg7l1st00002qz8kfe4w1xyz"],
    "addedToListIds": ["cmg7l1st00002qz8kfe4w1xyz"],
    "topicIds": ["cmg7t0p1c0003qz8k7h2d4pqr"],
    "requiresConfirmation": true,
    "confirmationSent": true
  }
}
FieldDescription
contactIdThe contact's id
statusACTIVE, or PENDING while a double opt-in is outstanding
createdtrue when this call created the contact
listIdsThe lists you asked for
addedToListIdsThe lists the contact was actually added to by this call (empty when it was already a member)
topicIdsThe topics you asked for
requiresConfirmationtrue when a confirmation email is required before the contact becomes active
confirmationSenttrue when the confirmation email was sent. It is false if platform sending is temporarily paused; retry later with Resend confirmation.

Webhooks: contact.subscribed (with source: "API"), plus one list.member_added per list in addedToListIds.

Double opt-in flow

  1. Your app calls POST /subscriptions with doubleOptIn: true. The contact is PENDING.
  2. The recipient receives a confirmation email with a link to https://app.northrelay.ca/c/<token>. The link is valid for 7 days.
  3. When they click it, the contact becomes ACTIVE, a contact.confirmed webhook fires, and the recipient is redirected to your redirectUrl (or shown a confirmation page).

List memberships and topic opt-ins are applied immediately, so the contact appears in the list, with status PENDING, as soon as you subscribe it. Campaigns skip PENDING contacts until they confirm.

Errors

CodeStatusWhenFix
VALIDATION_ERROR400Missing or invalid field, unknown field, or a non-https:// redirectUrlCorrect the field named in error.context.field
FREE_TIER_CONTACT_LIMIT403A Free account already holds 100 active contactsUpgrade at /dashboard/settings/billing
LIST_NOT_FOUND404A listIds entry does not exist in your accountCheck the id with GET /api/v1/contacts/lists
TOPIC_NOT_FOUND404A topicIds entry does not exist in your accountCheck the id with GET /api/v1/suppression-groups
RECIPIENT_UNSUBSCRIBED409The address unsubscribed (contact UNSUBSCRIBED, or an Unsubscribe or Manual suppression) and resubscribe was not setPass "resubscribe": true only with fresh consent, ideally with "doubleOptIn": true
RECIPIENT_SUPPRESSED409The address hard-bounced or complained, or you passed resubscribe for an address on your manual suppression listCannot be overridden by this API. Remove the suppression with DELETE /api/v1/suppressions/{email} only if you have confirmed the address is valid and wants your mail
CONTACT_BLOCKED409The contact's status is COMPLAINED or CLEANEDDelete the contact and only re-add it with fresh, documented consent

SDK

TypeScript (@northrelay/sdk 1.9.0). Methods return the response envelope, so the result is in data. Errors are thrown as NorthRelayError with code and fixAction; isSubscriptionError(error, code?) narrows them:

import { NorthRelayClient, isSubscriptionError } from '@northrelay/sdk';

const client = new NorthRelayClient({ apiKey: process.env.NORTHRELAY_API_KEY! });

try {
  const { data } = await client.subscriptions.subscribe({
    email: 'jane@example.com',
    listIds: ['cmg7l1st00002qz8kfe4w1xyz'],
    doubleOptIn: true,
    redirectUrl: 'https://yourapp.com/newsletter/confirmed',
    consent: { ip: req.ip, userAgent: req.headers['user-agent'], text: CONSENT_TEXT },
  });
  console.log(data.status, data.confirmationSent); // 'PENDING', true
} catch (error) {
  if (isSubscriptionError(error, 'RECIPIENT_UNSUBSCRIBED')) {
    // ask for fresh consent, then retry with resubscribe: true
  } else {
    throw error;
  }
}

Python (northrelay 1.9.0, async). The email is positional, the rest are keyword arguments; the result is a SubscribeResult model with snake_case attributes. A 409 raises NorthRelayError with the API code in error.details["code"]:

import os
from northrelay import NorthRelay, NorthRelayError

client = NorthRelay(api_key=os.environ["NORTHRELAY_API_KEY"])

try:
    result = await client.subscriptions.subscribe(
        "jane@example.com",
        list_ids=["cmg7l1st00002qz8kfe4w1xyz"],
        double_opt_in=True,
        redirect_url="https://yourapp.com/newsletter/confirmed",
        consent={"ip": client_ip, "user_agent": user_agent, "text": CONSENT_TEXT},
    )
    print(result.status, result.confirmation_sent)  # PENDING True
except NorthRelayError as error:
    if error.details.get("code") == "RECIPIENT_UNSUBSCRIBED":
        ...  # ask for fresh consent, then retry with resubscribe=True
    else:
        raise

Unsubscribe

Opt an address out from your own server, for example when a user switches off a newsletter in your product's settings.

POST /subscriptions/unsubscribe

Request body

FieldTypeRequiredDescription
emailstringYesRecipient address
scopestringNoall (default), list or topic
listIdstringWhen scope is listThe list to leave
topicIdstringWhen scope is topicThe topic to opt out of
reasonstringNoFree text, max 500 characters. Stored on the contact for scope: all and included in the webhook.

What each scope does:

ScopeEffect
allContact status becomes UNSUBSCRIBED and an account-level suppression with reason Unsubscribe is added. Works even when the address is not a contact. An existing Bounce, Complaint or Manual suppression is kept.
listThe contact leaves that list and no longer receives campaigns sent to it. The membership is marked removed so the history stays visible. No effect when the address is not a contact.
topicThe address is opted out of that topic and removed from every list tagged with it. Works even when the address is not a contact.

Example request

curl -X POST https://app.northrelay.ca/api/v1/subscriptions/unsubscribe \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "scope": "topic",
    "topicId": "cmg7t0p1c0003qz8k7h2d4pqr",
    "reason": "Turned off product updates in account settings"
  }'

Example response

{
  "success": true,
  "data": {
    "email": "jane@example.com",
    "contactId": "cmg7c0n1t0001qz8k2v5x9abc",
    "scope": "topic",
    "changed": true
  }
}

changed is false when the address was already opted out, so the call is safe to repeat. contactId is null when the address is not a contact. A contact.unsubscribed webhook (with method: "api") fires only when something changed, plus list.member_removed for each list the contact left.

Errors

CodeStatusWhenFix
VALIDATION_ERROR400Invalid email, unknown field, or listId / topicId missing for its scopeAdd the id that matches scope
LIST_NOT_FOUND404listId is not in your accountCheck the id with GET /api/v1/contacts/lists
TOPIC_NOT_FOUND404topicId is not in your accountCheck the id with GET /api/v1/suppression-groups

SDK

const { data } = await client.subscriptions.unsubscribe({
  email: 'jane@example.com',
  scope: 'topic',
  topicId: 'cmg7t0p1c0003qz8k7h2d4pqr',
});
// data.changed === false when they had already opted out
result = await client.subscriptions.unsubscribe(
    "jane@example.com", scope="topic", topic_id="cmg7t0p1c0003qz8k7h2d4pqr"
)
print(result.changed)

Get subscription status

Everything you need to render a preference page: the contact and its consent evidence, any account-level suppression, a pending double opt-in, every list membership (current and past) and every topic with a subscribed flag.

GET /subscriptions/{email}

URL-encode the address once (jane%2Bnews%40example.com for jane+news@example.com); do not double-encode it. An address that is not a contact still returns 200 with contact: null, so you can show topic choices before someone subscribes.

Example request

curl https://app.northrelay.ca/api/v1/subscriptions/jane%40example.com \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx"

Example response

{
  "success": true,
  "data": {
    "email": "jane@example.com",
    "contact": {
      "id": "cmg7c0n1t0001qz8k2v5x9abc",
      "status": "ACTIVE",
      "firstName": "Jane",
      "lastName": null,
      "subscribedAt": "2026-10-06T14:02:11.000Z",
      "unsubscribedAt": null,
      "confirmedAt": "2026-10-06T14:05:40.000Z",
      "consent": {
        "source": "api",
        "at": "2026-10-06T14:02:11.000Z",
        "ip": "203.0.113.24",
        "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6)",
        "text": "Yes, send me the monthly newsletter. I can unsubscribe at any time."
      }
    },
    "suppression": null,
    "unsubscribedFromAll": false,
    "pendingConfirmation": null,
    "lists": [
      {
        "id": "cmg7l1st00002qz8kfe4w1xyz",
        "name": "Monthly newsletter",
        "subscribed": true,
        "addedAt": "2026-10-06T14:02:11.000Z",
        "removedAt": null
      }
    ],
    "topics": [
      {
        "id": "cmg7t0p1c0003qz8k7h2d4pqr",
        "name": "product-updates",
        "label": "Product updates",
        "description": "New features and release notes, about twice a month.",
        "subscribed": true
      }
    ]
  }
}
FieldDescription
contactnull when the address is not a contact. status is one of PENDING, ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED, CLEANED.
contact.consentConsent evidence: source (for example api, form, import, preference_center), at, ip, userAgent, text. Any of them may be null.
suppressionAccount-level suppression { reason, since } (reason is Bounce, Complaint, Unsubscribe or Manual), or null
unsubscribedFromAlltrue when the contact is UNSUBSCRIBED or any account-level suppression exists. No marketing mail reaches the address.
pendingConfirmation{ expiresAt } while a double opt-in is outstanding, else null
listsEvery list the contact has been a member of, newest first. subscribed is false (with removedAt) after an unsubscribe.
topicsEvery topic in your account, ordered by sortOrder then name. label and description are the public wording you set on the topic (may be null). subscribed is false when the address opted out.

topics includes topics you have not published on NorthRelay's hosted preference centre; filter them in your page if needed.

Errors

CodeStatusWhenFix
VALIDATION_ERROR400The path is not a valid email addressURL-encode the address once

SDK

Both SDKs URL-encode the address for you.

const { data: status } = await client.subscriptions.get('jane@example.com');
const optedOut = status.topics.filter((t) => !t.subscribed);
status = await client.subscriptions.get("jane@example.com")
opted_out = [t for t in status.topics if not t.subscribed]
print(status.unsubscribed_from_all, status.contact.consent.text if status.contact else None)

Update preferences

Apply the choices from your preference page in one call. Returns the updated status in the same shape as Get subscription status.

PATCH /subscriptions/{email}

Request body

Provide at least one of topics, lists or unsubscribeAll. Unknown fields are rejected.

FieldTypeDescription
topicsobject{ "<topicId>": true | false }. true removes the opt-out, false opts out (and leaves lists tagged with the topic).
listsobject{ "<listId>": true | false }. true joins the list (the address must already be a contact), false leaves it.
unsubscribeAllbooleantrue unsubscribes from everything. When set, topics and lists in the same request are ignored.
resubscribebooleanRequired when joining a list for someone who unsubscribed from everything
consentobject{ ip?, userAgent?, text? }, recorded when the change re-subscribes the contact. When resubscribe is true and consent is omitted, the request's IP address is recorded.

Notes:

  • Turning a topic back on does not lift a global unsubscribe. To bring back someone who unsubscribed from everything, join a list with resubscribe: true, or call Subscribe with resubscribe: true.
  • Changes fire contact.subscribed, contact.unsubscribed, list.member_added and list.member_removed webhooks with method: "api" or source: "API".

Example request

curl -X PATCH https://app.northrelay.ca/api/v1/subscriptions/jane%40example.com \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "topics": { "cmg7t0p1c0003qz8k7h2d4pqr": false },
    "lists": { "cmg7l1st00002qz8kfe4w1xyz": true }
  }'

The response is the full status object shown under Get subscription status.

Errors

CodeStatusWhenFix
VALIDATION_ERROR400Invalid email, unknown field, or none of topics, lists, unsubscribeAllSend at least one of them
LIST_NOT_FOUND404A list id is not in your accountCheck the id with GET /api/v1/contacts/lists
TOPIC_NOT_FOUND404A topic id is not in your accountCheck the id with GET /api/v1/suppression-groups
CONTACT_NOT_FOUND404Joining a list for an address that is not a contactSubscribe it first with POST /api/v1/subscriptions
RECIPIENT_UNSUBSCRIBED409Joining a list for someone who unsubscribed from everything, without resubscribePass "resubscribe": true with fresh consent
RECIPIENT_SUPPRESSED409Joining a list for an address that bounced, complained or is manually suppressedCannot be overridden by this API
CONTACT_BLOCKED409The contact is COMPLAINED or CLEANEDOnly re-add with fresh, documented consent

SDK

const { data: status } = await client.subscriptions.updatePreferences('jane@example.com', {
  topics: { cmg7t0p1c0003qz8k7h2d4pqr: false },
});
status = await client.subscriptions.update_preferences(
    "jane@example.com",
    topics={"cmg7t0p1c0003qz8k7h2d4pqr": False},
)

Python takes unsubscribe_all=True for unsubscribeAll and raises ValueError before calling the API when none of topics, lists or unsubscribe_all is given.


Resend confirmation

Send the double opt-in email again for a PENDING contact that was subscribed through this API, and move the link's expiry to 7 days from now.

POST /subscriptions/{email}/resend-confirmation

No request body.

Example request

curl -X POST https://app.northrelay.ca/api/v1/subscriptions/jane%40example.com/resend-confirmation \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx"

Example response

{
  "success": true,
  "data": {
    "email": "jane@example.com",
    "confirmationSent": true,
    "expiresAt": "2026-10-13T15:10:00.000Z"
  }
}

Errors

CodeStatusWhenFix
VALIDATION_ERROR400The path is not a valid email addressURL-encode the address once
CONTACT_NOT_FOUND404The address is not a contactSubscribe it first with POST /api/v1/subscriptions
NO_PENDING_CONFIRMATION409The contact is not PENDING, is already confirmed, or was subscribed through a signup formSubscribe it again with "doubleOptIn": true to send a new confirmation

SDK

const { data } = await client.subscriptions.resendConfirmation('jane@example.com');
console.log(data.expiresAt);
result = await client.subscriptions.resend_confirmation("jane@example.com")
print(result.expires_at)

Neither SDK retries subscribe or resendConfirmation automatically, because a retry could send a second confirmation email.


Marketing mail from NorthRelay carries a signed, per-recipient unsubscribe token:

  • Campaign mail: the {{unsubscribe_url}} template variable, plus List-Unsubscribe and List-Unsubscribe-Post headers (RFC 8058).
  • API mail on a marketing message stream: NorthRelay injects the unsubscribe footer and the same headers automatically.

The unsubscribe link has the form https://track.north-pipe.ca/u/{token}. The token identifies your account and the recipient, so the endpoints below need no API key. Unsubscribes from API mail are attributed to your account and fire contact.unsubscribed even when the recipient is not one of your contacts.

Campaign templates can also use {{preferences_url}}, a signed link to NorthRelay's hosted preference centre where the recipient can choose topics or unsubscribe from everything.

Process an unsubscribe from your own page

If you host your own unsubscribe landing page, send the token and the recipient's choice here. This endpoint is public; the token is the authentication.

POST /unsubscribe
FieldTypeRequiredDescription
tokenstringYesThe token from the unsubscribe link
scopestringNoall (default), list or topic. category is accepted as a legacy alias of topic, and upper case is accepted.
listIdstringWhen scope is listThe list to leave
topicIdstringWhen scope is topicThe topic to opt out of (legacy name: suppressionGroupId)
reasonstringNoMax 500 characters
curl -X POST https://app.northrelay.ca/api/v1/unsubscribe \
  -H "Content-Type: application/json" \
  -d '{ "token": "<token from the unsubscribe link>", "scope": "all" }'
{
  "success": true,
  "message": "Successfully unsubscribed from all marketing emails",
  "data": { "scope": "all", "changed": true }
}
CodeStatusWhenFix
VALIDATION_ERROR400token missing, body invalid, or listId / topicId missing for its scopeSend { "token": "...", "scope": "all" | "list" | "topic" } with the matching id
INVALID_TOKEN400The token is forged, malformed or cannot be matched to a recipientUse the unsubscribe link from the most recent email
LIST_NOT_FOUND / TOPIC_NOT_FOUND404The id is not in the sender's accountPass an id from the sender's account

A bare contactId is no longer accepted: it let anyone who learned a contact id opt that person out. For opt-outs from your own server use POST /subscriptions/unsubscribe with your API key.

One-click unsubscribe (RFC 8058)

POST /unsubscribe/one-click?token={token}

Mailbox providers (Gmail, Yahoo, Outlook) call the one-click URL from the List-Unsubscribe header when the recipient presses their built-in "Unsubscribe" button; NorthRelay's tracking service forwards those requests here. It unsubscribes the recipient from all marketing mail (webhook method: "one_click") and always answers 200, as RFC 8058 requires, even when the token cannot be resolved. A missing token returns 400.

GET /unsubscribe/one-click?token= does not unsubscribe. Link scanners and security gateways prefetch URLs, so a GET redirects (303) to the confirmation page, where the recipient presses a button.


On this page