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
| Term | What it is | Managed with |
|---|---|---|
| List | An audience you send campaigns to. A contact is either a member or not. | /contacts/lists |
| Topic | A 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 unsubscribe | The 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
COMPLAINEDorCLEANEDcannot 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:
| Endpoint | Scope |
|---|---|
GET /subscriptions/{email} | contacts:read |
POST /subscriptions, POST /subscriptions/unsubscribe, PATCH /subscriptions/{email}, POST /subscriptions/{email}/resend-confirmation | contacts: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 /subscriptionsReturns 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.
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Recipient address. Trimmed and lowercased. Max 254 characters. |
firstName | string | No | Max 100 characters |
lastName | string | No | Max 100 characters |
phone | string | No | Max 40 characters |
listIds | string[] | No | Lists to add the contact to (max 50) |
topicIds | string[] | No | Topics to opt the contact into, i.e. remove any opt-out (max 50) |
tags | string[] | No | Tags to add (max 50, each 1–50 characters) |
customFields | object | No | String key/value pairs stored as custom fields (keys max 100, values max 1,000 characters) |
metadata | object | No | Arbitrary JSON stored on the contact (replaces existing metadata) |
doubleOptIn | boolean | No | Send a confirmation email and keep the contact PENDING until confirmed. Ignored for a contact that is already ACTIVE. |
confirmationTemplateId | string | No | One 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. |
redirectUrl | string | No | https:// URL the recipient is sent to after confirming. Without it they see a NorthRelay confirmation page. |
resubscribe | boolean | No | Required to re-add someone who previously unsubscribed. Only use it with fresh consent. |
consent | object | No | Consent evidence: { ip?, userAgent?, text? }. See below. |
Consent evidence
NorthRelay stores the consent source (api for this endpoint), the time, and whatever you pass in consent:
| Field | Type | Description |
|---|---|---|
consent.ip | string | The subscriber's IP address. Pass it from your server: NorthRelay only sees your server's address. Max 64 characters. |
consent.userAgent | string | The subscriber's browser user agent. Max 1,024 characters. |
consent.text | string | The 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
}
}| Field | Description |
|---|---|
contactId | The contact's id |
status | ACTIVE, or PENDING while a double opt-in is outstanding |
created | true when this call created the contact |
listIds | The lists you asked for |
addedToListIds | The lists the contact was actually added to by this call (empty when it was already a member) |
topicIds | The topics you asked for |
requiresConfirmation | true when a confirmation email is required before the contact becomes active |
confirmationSent | true 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
- Your app calls
POST /subscriptionswithdoubleOptIn: true. The contact isPENDING. - The recipient receives a confirmation email with a link to
https://app.northrelay.ca/c/<token>. The link is valid for 7 days. - When they click it, the contact becomes
ACTIVE, acontact.confirmedwebhook fires, and the recipient is redirected to yourredirectUrl(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
| Code | Status | When | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | Missing or invalid field, unknown field, or a non-https:// redirectUrl | Correct the field named in error.context.field |
FREE_TIER_CONTACT_LIMIT | 403 | A Free account already holds 100 active contacts | Upgrade at /dashboard/settings/billing |
LIST_NOT_FOUND | 404 | A listIds entry does not exist in your account | Check the id with GET /api/v1/contacts/lists |
TOPIC_NOT_FOUND | 404 | A topicIds entry does not exist in your account | Check the id with GET /api/v1/suppression-groups |
RECIPIENT_UNSUBSCRIBED | 409 | The address unsubscribed (contact UNSUBSCRIBED, or an Unsubscribe or Manual suppression) and resubscribe was not set | Pass "resubscribe": true only with fresh consent, ideally with "doubleOptIn": true |
RECIPIENT_SUPPRESSED | 409 | The address hard-bounced or complained, or you passed resubscribe for an address on your manual suppression list | Cannot 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_BLOCKED | 409 | The contact's status is COMPLAINED or CLEANED | Delete 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:
raiseUnsubscribe
Opt an address out from your own server, for example when a user switches off a newsletter in your product's settings.
POST /subscriptions/unsubscribeRequest body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Recipient address |
scope | string | No | all (default), list or topic |
listId | string | When scope is list | The list to leave |
topicId | string | When scope is topic | The topic to opt out of |
reason | string | No | Free text, max 500 characters. Stored on the contact for scope: all and included in the webhook. |
What each scope does:
| Scope | Effect |
|---|---|
all | Contact 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. |
list | The 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. |
topic | The 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
| Code | Status | When | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | Invalid email, unknown field, or listId / topicId missing for its scope | Add the id that matches scope |
LIST_NOT_FOUND | 404 | listId is not in your account | Check the id with GET /api/v1/contacts/lists |
TOPIC_NOT_FOUND | 404 | topicId is not in your account | Check 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 outresult = 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
}
]
}
}| Field | Description |
|---|---|
contact | null when the address is not a contact. status is one of PENDING, ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED, CLEANED. |
contact.consent | Consent evidence: source (for example api, form, import, preference_center), at, ip, userAgent, text. Any of them may be null. |
suppression | Account-level suppression { reason, since } (reason is Bounce, Complaint, Unsubscribe or Manual), or null |
unsubscribedFromAll | true 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 |
lists | Every list the contact has been a member of, newest first. subscribed is false (with removedAt) after an unsubscribe. |
topics | Every 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
| Code | Status | When | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | The path is not a valid email address | URL-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.
| Field | Type | Description |
|---|---|---|
topics | object | { "<topicId>": true | false }. true removes the opt-out, false opts out (and leaves lists tagged with the topic). |
lists | object | { "<listId>": true | false }. true joins the list (the address must already be a contact), false leaves it. |
unsubscribeAll | boolean | true unsubscribes from everything. When set, topics and lists in the same request are ignored. |
resubscribe | boolean | Required when joining a list for someone who unsubscribed from everything |
consent | object | { 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 withresubscribe: true. - Changes fire
contact.subscribed,contact.unsubscribed,list.member_addedandlist.member_removedwebhooks withmethod: "api"orsource: "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
| Code | Status | When | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | Invalid email, unknown field, or none of topics, lists, unsubscribeAll | Send at least one of them |
LIST_NOT_FOUND | 404 | A list id is not in your account | Check the id with GET /api/v1/contacts/lists |
TOPIC_NOT_FOUND | 404 | A topic id is not in your account | Check the id with GET /api/v1/suppression-groups |
CONTACT_NOT_FOUND | 404 | Joining a list for an address that is not a contact | Subscribe it first with POST /api/v1/subscriptions |
RECIPIENT_UNSUBSCRIBED | 409 | Joining a list for someone who unsubscribed from everything, without resubscribe | Pass "resubscribe": true with fresh consent |
RECIPIENT_SUPPRESSED | 409 | Joining a list for an address that bounced, complained or is manually suppressed | Cannot be overridden by this API |
CONTACT_BLOCKED | 409 | The contact is COMPLAINED or CLEANED | Only 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-confirmationNo 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
| Code | Status | When | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | The path is not a valid email address | URL-encode the address once |
CONTACT_NOT_FOUND | 404 | The address is not a contact | Subscribe it first with POST /api/v1/subscriptions |
NO_PENDING_CONFIRMATION | 409 | The contact is not PENDING, is already confirmed, or was subscribed through a signup form | Subscribe 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.
Unsubscribe links
Marketing mail from NorthRelay carries a signed, per-recipient unsubscribe token:
- Campaign mail: the
{{unsubscribe_url}}template variable, plusList-UnsubscribeandList-Unsubscribe-Postheaders (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| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The token from the unsubscribe link |
scope | string | No | all (default), list or topic. category is accepted as a legacy alias of topic, and upper case is accepted. |
listId | string | When scope is list | The list to leave |
topicId | string | When scope is topic | The topic to opt out of (legacy name: suppressionGroupId) |
reason | string | No | Max 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 }
}| Code | Status | When | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | token missing, body invalid, or listId / topicId missing for its scope | Send { "token": "...", "scope": "all" | "list" | "topic" } with the matching id |
INVALID_TOKEN | 400 | The token is forged, malformed or cannot be matched to a recipient | Use the unsubscribe link from the most recent email |
LIST_NOT_FOUND / TOPIC_NOT_FOUND | 404 | The id is not in the sender's account | Pass 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.
Related
- Mailing Lists guide: build signup, double opt-in and a preference page end to end
- Contacts API: contacts, lists, members, CSV import
- Suppressions API: topics (suppression groups) and account-level suppressions
- Webhooks:
contact.*,list.*,email.opened,email.clicked