NorthRelayNorthRelay
Guides

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

ConceptWhat it meansAPI
ContactOne recipient address in your account, with status (PENDING, ACTIVE, UNSUBSCRIBED, BOUNCED, COMPLAINED, CLEANED) and consent evidenceContacts
ListAn audience you send campaigns to, such as "Monthly newsletter"Contact lists
TopicA 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 formSubscriptions API
Where it runsBrowser posts straight to NorthRelay (POST /api/v1/forms/{id}/submit, public, CORS)Your server calls POST /api/v1/subscriptions with your API key
Best forMarketing sites, landing pages, no backendSignups inside your product, checkout opt-ins, account settings
Spam protectionHoneypot field, allowed origins, rate limits, optional Cloudflare TurnstileYours (you control the endpoint)
Double opt-inPer-form settingdoubleOptIn: true per call, with redirectUrl
Consent evidenceStored 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
TopicsThe 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_confirmation

Or 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:

CodeWhat to tell the user
RECIPIENT_UNSUBSCRIBEDThey unsubscribed earlier. If they are signing up again now, that is fresh consent: retry with "resubscribe": true and "doubleOptIn": true.
RECIPIENT_SUPPRESSEDThe address bounced or reported spam. Ask for a different address.
CONTACT_BLOCKEDSame as above; this address cannot be re-added through the API.
FREE_TIER_CONTACT_LIMITYour account is at its contact limit; upgrade the plan.

3. Double opt-in

With doubleOptIn: true:

  1. The contact is created as PENDING and added to the lists. Campaigns skip it while it is PENDING.
  2. NorthRelay emails a confirmation link (https://app.northrelay.ca/c/<token>, valid for 7 days). Pass confirmationTemplateId to use your own template; it can use {{confirm_url}}, {{first_name}}, {{last_name}} and {{email}}.
  3. The recipient clicks; the contact becomes ACTIVE, the contact.confirmed webhook fires, and the browser is redirected to your redirectUrl (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.

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:

FieldSource
consentSourceHow they subscribed: api, form, import, preference_center
consentAtWhen
consentIp, consentUserAgentWhat you passed in consent (signup forms record the submitting browser)
consentTextThe wording they agreed to (signup forms record Signup form: <form name>)
confirmedAtWhen 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 them

Render 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:

  • GET returns 200 with contact: null for someone who never subscribed, so the page works for every user.
  • topics lists 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 (or PATCH with lists) with "resubscribe": true and fresh consent.

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 the List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers 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_TOKEN means 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-Unsubscribe header 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/unsubscribe from 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"
    ]
  }'
EventTypical use
contact.subscribedMark the user as "newsletter: pending" or "subscribed" (details.status)
contact.confirmedFlip "pending" to "subscribed"; send a welcome email or a coupon
contact.unsubscribedTurn off the matching setting in your app (details.scope: all, list or topic)
list.member_added / list.member_removedMirror list membership if you show it in your UI
email.opened / email.clickedEngagement scoring, "last seen" in your CRM. Human activity only; bots are filtered.
email.bouncedAsk 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/bulk with reason Unsubscribe, Bounce or Complaint), 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 Bounce suppression; a spam complaint adds a Complaint suppression. 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 suppressionStatuses in GET /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 /subscriptions from your server with doubleOptIn, redirectUrl and consent
  • Signup handler handles RECIPIENT_UNSUBSCRIBED and RECIPIENT_SUPPRESSED
  • Campaign templates include {{unsubscribe_url}} and {{preferences_url}}
  • Preference page uses GET and PATCH /subscriptions/{email}
  • Webhook endpoint verifies signatures and handles contact.* events
  • Old provider's suppressions imported before the contacts

On this page