Mailing Lists and Newsletters
Build newsletter signup, double opt-in, a preference page and unsubscribe handling in your own app on top of NorthRelay.
Mailing Lists and Newsletters
This guide shows how to build mailing-list features into your own product with NorthRelay: a newsletter signup, double opt-in, a preference page inside your app, unsubscribe links, and keeping your database in sync. NorthRelay holds the audience and enforces the opt-out rules; your app calls the API and listens to webhooks.
You need an API key with contacts:read and contacts:write (and suppressions:write if you create topics through the API). Campaigns are available on Micro and above; lists, contacts and the Subscriptions API work on every plan. On Free, subscriptions and signup forms can activate up to 100 contacts.
The building blocks
| Concept | What it means | API |
|---|---|---|
| Contact | One recipient address in your account, with status (PENDING, ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED, CLEANED) and consent evidence | Contacts |
| List | An audience you send campaigns to, such as "Monthly newsletter" | Contact lists |
| Topic | A kind of mail people can opt out of on its own, such as "Product updates" or "Events". Stored as a suppression group whose members are the people who opted out. | Topics |
| Global unsubscribe | "Stop all marketing mail." Contact becomes UNSUBSCRIBED and an account-level Unsubscribe suppression is added. | Subscriptions |
Lists or topics? Use a list for "who do I send this to" and a topic for "what kind of mail is this". A common setup is one topic per kind of mail, and each list tagged with its topic. Then:
- opting out of the topic removes the person from every list tagged with it, and
- any marketing send under that topic (a campaign to a tagged list, a campaign with that subscription topic, or a marketing-stream API send with
suppressionGroupId) skips people who opted out, whichever list they are on.
Campaigns go only to current members of the list: someone who unsubscribed from the list (scope: "list") or has not yet confirmed a double opt-in (PENDING) is not sent the campaign. Tag your lists with topics as well, so that one opt-out covers every list and every send of that kind of mail.
1. Set up a topic and a list
Create the topic, give it the wording recipients will see, and create a list tagged with it.
# Topic
curl -X POST https://app.northrelay.ca/api/v1/suppression-groups \
-H "Authorization: Bearer $NORTHRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "newsletter", "description": "Monthly newsletter" }'
# Public wording, shown on the hosted preference centre
curl -X PATCH https://app.northrelay.ca/api/v1/suppression-groups/$TOPIC_ID \
-H "Authorization: Bearer $NORTHRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "publicLabel": "Monthly newsletter", "publicDescription": "One email a month with product news.", "isPublishedOnUnsub": true }'
# List tagged with the topic
curl -X POST https://app.northrelay.ca/api/v1/contacts/lists \
-H "Authorization: Bearer $NORTHRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Monthly newsletter", "suppressionGroupId": "'$TOPIC_ID'" }'You can do the same in the dashboard under Contacts.
2. Collect signups
There are two ways to get people onto a list.
| Hosted signup form | Subscriptions API | |
|---|---|---|
| Where it runs | Browser posts straight to NorthRelay (POST /api/v1/forms/{id}/submit, public, CORS) | Your server calls POST /api/v1/subscriptions with your API key |
| Best for | Marketing sites, landing pages, no backend | Signups inside your product, checkout opt-ins, account settings |
| Spam protection | Honeypot field, allowed origins, rate limits, optional Cloudflare Turnstile | Yours (you control the endpoint) |
| Double opt-in | Per-form setting | doubleOptIn: true per call, with redirectUrl |
| Consent evidence | Stored on the contact automatically: source form, time, submission IP and user agent, and the text Signup form: <form name> | consent.ip, consent.userAgent, consent.text you pass, stored on the contact with source api |
| Topics | The form's list (and its topic) | Any listIds and topicIds |
Never put your API key in browser code. If the signup happens in the browser and you want the API's features, post to your own backend and call the API from there.
Subscribing from your backend
import { NorthRelayClient, isSubscriptionError } from '@northrelay/sdk';
const client = new NorthRelayClient({ apiKey: process.env.NORTHRELAY_API_KEY! });
const CONSENT_TEXT = 'Yes, send me the monthly newsletter. I can unsubscribe at any time.';
export async function POST(req: Request) {
const { email, firstName } = await req.json();
try {
const { data } = await client.subscriptions.subscribe({
email,
firstName,
listIds: [process.env.NEWSLETTER_LIST_ID!],
doubleOptIn: true,
redirectUrl: 'https://yourapp.com/newsletter/confirmed',
consent: {
ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim(),
userAgent: req.headers.get('user-agent') ?? undefined,
text: CONSENT_TEXT,
},
});
return Response.json({ pending: data.requiresConfirmation });
} catch (error) {
if (isSubscriptionError(error, 'RECIPIENT_SUPPRESSED') || isSubscriptionError(error, 'CONTACT_BLOCKED')) {
return Response.json({ error: 'Please use a different email address.' }, { status: 409 });
}
throw error;
}
}In Python (northrelay 1.9.0 is async):
import os
from northrelay import NorthRelay, NorthRelayError
client = NorthRelay(api_key=os.environ["NORTHRELAY_API_KEY"])
async def newsletter_signup(email: str, first_name: str, ip: str, user_agent: str) -> bool:
try:
result = await client.subscriptions.subscribe(
email,
first_name=first_name,
list_ids=[os.environ["NEWSLETTER_LIST_ID"]],
double_opt_in=True,
redirect_url="https://yourapp.com/newsletter/confirmed",
consent={"ip": ip, "user_agent": user_agent, "text": CONSENT_TEXT},
)
except NorthRelayError as error:
if error.details.get("code") in ("RECIPIENT_SUPPRESSED", "CONTACT_BLOCKED"):
raise ValueError("Please use a different email address.") from error
raise
return result.requires_confirmationOr with curl:
curl -X POST https://app.northrelay.ca/api/v1/subscriptions \
-H "Authorization: Bearer $NORTHRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"listIds": ["'$LIST_ID'"],
"doubleOptIn": true,
"redirectUrl": "https://yourapp.com/newsletter/confirmed",
"consent": { "ip": "203.0.113.24", "text": "Yes, send me the monthly newsletter." }
}'The call is idempotent: calling it again for someone already on the list returns 200 and adds nothing.
Handle these errors in your signup handler:
| Code | What to tell the user |
|---|---|
RECIPIENT_UNSUBSCRIBED | They unsubscribed earlier. If they are signing up again now, that is fresh consent: retry with "resubscribe": true and "doubleOptIn": true. |
RECIPIENT_SUPPRESSED | The address bounced or reported spam. Ask for a different address. |
CONTACT_BLOCKED | Same as above; this address cannot be re-added through the API. |
FREE_TIER_CONTACT_LIMIT | Your account is at its contact limit; upgrade the plan. |
3. Double opt-in
With doubleOptIn: true:
- The contact is created as
PENDINGand added to the lists. Campaigns skip it while it isPENDING. - NorthRelay emails a confirmation link (
https://app.northrelay.ca/c/<token>, valid for 7 days). PassconfirmationTemplateIdto use your own template; it can use{{confirm_url}},{{first_name}},{{last_name}}and{{email}}. - The recipient clicks; the contact becomes
ACTIVE, thecontact.confirmedwebhook fires, and the browser is redirected to yourredirectUrl(https://only).
If someone says the email never arrived, call POST /api/v1/subscriptions/{email}/resend-confirmation. It re-sends the email and gives the link another 7 days.
Double opt-in is the strongest consent record you can have, and it keeps typos and bots off your list, which protects your deliverability. Use it for anything public-facing.
4. Keep consent evidence (CASL and GDPR)
Canada's anti-spam law (CASL) and the EU's GDPR expect you to show when and how each person agreed to receive marketing mail. For every API subscribe call and every signup form submission, NorthRelay stores on the contact:
| Field | Source |
|---|---|
consentSource | How they subscribed: api, form, import, preference_center |
consentAt | When |
consentIp, consentUserAgent | What you passed in consent (signup forms record the submitting browser) |
consentText | The wording they agreed to (signup forms record Signup form: <form name>) |
confirmedAt | When they confirmed the double opt-in |
Pass consent.text with the exact label of the checkbox or button the person used, and pass their IP from your server (NorthRelay only sees your server's address). Read it back at any time:
curl https://app.northrelay.ca/api/v1/subscriptions/jane%40example.com \
-H "Authorization: Bearer $NORTHRELAY_API_KEY"This guide is not legal advice; check what your jurisdiction and audience require.
5. A preference page inside your product
Let signed-in users manage their email from your own settings screen. Read the current state with GET /subscriptions/{email} and save their choices with PATCH /subscriptions/{email}.
// Load: everything the page needs in one call
const { data: status } = await client.subscriptions.get(user.email);
// status.topics -> [{ id, name, label, description, subscribed }]
// status.lists -> [{ id, name, subscribed }]
// status.unsubscribedFromAll -> true when no marketing mail reaches themRender a checkbox per topic (use label and description, falling back to name), and a "Unsubscribe from all marketing email" option. On save, send only what changed:
await client.subscriptions.updatePreferences(user.email, {
topics: {
[productUpdatesTopicId]: form.productUpdates, // true = receive, false = opt out
[eventsTopicId]: form.events,
},
});
// "Unsubscribe from everything"
await client.subscriptions.updatePreferences(user.email, { unsubscribeAll: true });status = await client.subscriptions.get(user.email)
# status.topics -> [TopicSubscription(id, name, label, description, subscribed)]
await client.subscriptions.update_preferences(
user.email,
topics={product_updates_topic_id: True, events_topic_id: False},
)
# "Unsubscribe from everything"
await client.subscriptions.update_preferences(user.email, unsubscribe_all=True)Things to know:
GETreturns200withcontact: nullfor someone who never subscribed, so the page works for every user.topicslists every topic in your account. If some are internal, filter them out.- Turning a topic back on does not undo "unsubscribe from all". To bring someone back, the user has to opt in again; then call
POST /subscriptions(orPATCHwithlists) with"resubscribe": trueand freshconsent.
6. Unsubscribe links and one-click unsubscribe
Every marketing message needs a working unsubscribe link, and Gmail, Yahoo and Outlook require RFC 8058 one-click unsubscribe headers for bulk senders. NorthRelay handles both:
- Campaigns: put
{{unsubscribe_url}}in your template footer. NorthRelay adds theList-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Clickheaders to every message. - Preference link: put
{{preferences_url}}next to it to give recipients a signed link to NorthRelay's hosted preference centre, where they can choose topics or unsubscribe from everything. - API mail on a marketing message stream: NorthRelay injects the unsubscribe footer and the same headers automatically. Unsubscribes from these messages are attributed to your account and fire
contact.unsubscribed, even when the recipient is not one of your contacts.
When a mailbox provider's "Unsubscribe" button is pressed, NorthRelay unsubscribes the recipient from all marketing mail and fires contact.unsubscribed with method: "one_click". A plain GET of the one-click URL never unsubscribes anyone, because security scanners prefetch links.
<p style="font-size:12px;color:#6b7280">
You are receiving this because you subscribed at yourapp.com.
<a href="{{preferences_url}}">Manage preferences</a> ·
<a href="{{unsubscribe_url}}">Unsubscribe</a>
</p>7. Hosting your own unsubscribe page
NorthRelay's hosted unsubscribe and preference pages work out of the box. If you would rather keep recipients on your own domain for campaign mail, link to your page and pass along the NorthRelay link:
<a href="https://yourapp.com/unsubscribe?u={{unsubscribe_url}}">Unsubscribe</a>The unsubscribe link has the form https://track.north-pipe.ca/u/{token}. Your page takes the last path segment as the token, asks the recipient what they want, and posts it to the public endpoint (no API key; the token is the authentication):
// yourapp.com/unsubscribe, server action
const link = new URL(searchParams.get('u')!);
const token = link.pathname.split('/').pop()!;
const res = await fetch('https://app.northrelay.ca/api/v1/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, scope: 'all' }), // or scope "list" + listId, "topic" + topicId
});
const body = await res.json();
if (!body.success) {
// INVALID_TOKEN: the link is old or altered. Ask them to use the latest email,
// or offer to unsubscribe them after they sign in.
}Notes:
- Tokens are signed and expire.
INVALID_TOKENmeans the link is too old or was altered. - The page must not unsubscribe on page load (link scanners open it). Do it when the recipient presses a button.
- The
List-Unsubscribeheader still points at NorthRelay, so one-click from the mailbox provider keeps working without your page. - For opt-outs made by a signed-in user in your product, skip the token and call
POST /api/v1/subscriptions/unsubscribefrom your server with your API key.
8. Keep your app in sync with webhooks
Register one webhook endpoint for the contact and list events, and optionally engagement events:
curl -X POST https://app.northrelay.ca/api/v1/webhooks \
-H "Authorization: Bearer $NORTHRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/northrelay",
"events": [
"contact.subscribed", "contact.confirmed", "contact.unsubscribed",
"list.member_added", "list.member_removed",
"email.opened", "email.clicked", "email.bounced"
]
}'| Event | Typical use |
|---|---|
contact.subscribed | Mark the user as "newsletter: pending" or "subscribed" (details.status) |
contact.confirmed | Flip "pending" to "subscribed"; send a welcome email or a coupon |
contact.unsubscribed | Turn off the matching setting in your app (details.scope: all, list or topic) |
list.member_added / list.member_removed | Mirror list membership if you show it in your UI |
email.opened / email.clicked | Engagement scoring, "last seen" in your CRM. Human activity only; bots are filtered. |
email.bounced | Ask the user to update their address |
A minimal handler:
import { parseWebhookPayload, type WebhookPayload } from '@northrelay/sdk';
export async function POST(req: Request) {
const raw = await req.text();
let event: WebhookPayload;
try {
// Verifies X-NorthRelay-Signature, then parses into a typed WebhookPayload
event = parseWebhookPayload(raw, req.headers.get('x-northrelay-signature') ?? '', process.env.NORTHRELAY_WEBHOOK_SECRET!);
} catch {
return new Response('invalid signature', { status: 401 });
}
switch (event.eventType) {
case 'contact.confirmed':
await db.user.updateMany({ where: { email: event.details.email }, data: { newsletter: 'subscribed' } });
break;
case 'contact.unsubscribed':
if (event.details.scope === 'all') {
await db.user.updateMany({ where: { email: event.details.contactEmail }, data: { newsletter: 'unsubscribed' } });
} else if (event.details.scope === 'topic') {
await db.emailPreference.upsert({ /* topicId: event.details.topicId, enabled: false */ });
}
break;
}
return new Response('ok');
}The Python SDK has a WebhookPayload model (event_type, message_id, timestamp, recipient, details); verify the signature with hmac.compare_digest over the raw body first, as shown in the Webhooks reference.
Respond within 10 seconds and do slow work asynchronously. Payload details are in the Webhooks reference.
9. Import an existing list
Moving from another provider? Import the CSV straight into a list:
curl -X POST https://app.northrelay.ca/api/v1/contacts/import \
-H "Authorization: Bearer $NORTHRELAY_API_KEY" \
-F "file=@subscribers.csv" \
-F 'mappings=[{"csvColumn":"Email Address","field":"email"},{"csvColumn":"First Name","field":"firstName"}]' \
-F "listId=$LIST_ID" \
-F "tags=imported-2026-10"The response reports imported, skipped (already existed), addedToList and per-row errors. Each request takes up to 1,000 rows and 2 MiB; split larger files.
With the SDKs:
const result = await client.contacts.importCsv(csvText, {
mappings: [
{ csvColumn: 'Email Address', field: 'email' },
{ csvColumn: 'First Name', field: 'firstName' },
],
listId,
tags: ['imported-2026-10'],
});
// result: { imported, skipped, addedToList, errors }result = await client.contacts.import_csv(
"subscribers.csv",
mappings=[{"csvColumn": "Email Address", "field": "email"}, {"csvColumn": "First Name", "field": "firstName"}],
list_id=list_id,
tags=["imported-2026-10"],
)
print(result.imported, result.added_to_list)Before importing:
- Only import people who opted in to your mail. Imported contacts are recorded with consent source
import. - Import your old provider's unsubscribes, bounces and complaints as suppressions first (
POST /api/v1/suppressions/bulkwithreasonUnsubscribe,BounceorComplaint), so they are never mailed. - Imports do not fire webhooks. Sync your app from your source data, not from webhooks, for the initial load.
10. Bounce and complaint protection
NorthRelay protects your sender reputation, and the shared sending IP, automatically:
- A hard bounce adds a
Bouncesuppression; a spam complaint adds aComplaintsuppression. Neither is ever lifted by a subscribe call, and both block even transactional mail. - Marketing sends also skip anyone who unsubscribed from everything or opted out of the topic the mail is sent under, and campaigns skip members who left the list or have not confirmed their double opt-in.
- Adding an existing contact to a list does not make it mailable again: suppressed and unsubscribed members stay in the list but are skipped at send time and shown with
suppressionStatusesinGET /contacts/lists/{id}/members. - Suppressed recipients are dropped before quota is counted.
Keep complaint rates low by using double opt-in, sending what people signed up for, putting the preference and unsubscribe links where they are easy to find, and removing people who have not engaged in a long time.
Checklist
- Topics created, with public labels, and each list tagged with its topic
- Signup calls
POST /subscriptionsfrom your server withdoubleOptIn,redirectUrlandconsent - Signup handler handles
RECIPIENT_UNSUBSCRIBEDandRECIPIENT_SUPPRESSED - Campaign templates include
{{unsubscribe_url}}and{{preferences_url}} - Preference page uses
GETandPATCH /subscriptions/{email} - Webhook endpoint verifies signatures and handles
contact.*events - Old provider's suppressions imported before the contacts