NorthRelayNorthRelay
SDKs

Python SDK

Official NorthRelay Python client library (async, typed with Pydantic v2) for sending email, managing contacts, mailing lists and subscriptions

Python SDK

The official NorthRelay Python SDK (northrelay 1.9.0) is an async client built on httpx, with Pydantic v2 models for requests and results.

Requires Python 3.9+. Every API method is a coroutine: call it with await inside an async function (FastAPI, Starlette, aiohttp, or asyncio.run).

Installation

pip install northrelay

Quick Start

import asyncio
import os

from northrelay import NorthRelay, SendEmailRequest


async def main() -> None:
    async with NorthRelay(api_key=os.environ["NORTHRELAY_API_KEY"]) as client:
        response = await client.emails.send(
            SendEmailRequest(
                from_={"email": "you@yourdomain.com", "name": "Your App"},
                to=[{"email": "recipient@example.com"}],
                content={
                    "subject": "Hello from NorthRelay",
                    "html": "<h1>Welcome!</h1><p>Your first email via NorthRelay.</p>",
                },
            )
        )
        print("Queued:", response.message_id)


asyncio.run(main())

async with closes the HTTP connection pool on exit; without it, call await client.close() when you are done.

Configuration

client = NorthRelay(
    api_key="nr_live_xxx",                  # required: nr_live_, nr_test_ or nr_app_
    base_url="https://app.northrelay.ca",   # optional, defaults shown
    timeout=30.0,                           # seconds
    max_retries=3,
    retry_delay=1.0,                        # seconds, exponential backoff
    max_retry_delay=10.0,
)
OptionTypeDefaultDescription
api_keystrrequiredYour NorthRelay API key
base_urlstrhttps://app.northrelay.caAPI base URL
timeoutfloat30.0Request timeout in seconds
max_retriesint3Max attempts for retryable reads
retry_delayfloat1.0Initial retry delay in seconds
max_retry_delayfloat10.0Cap on the backoff delay

Mailing Lists and Subscriptions

See the Mailing Lists guide for the full integration and the Subscriptions API for request and response details. Results are Pydantic models with snake_case attributes (result.contact_id, status.unsubscribed_from_all).

Subscriptions

from northrelay import NorthRelayError

# Subscribe with double opt-in and consent evidence (email is positional)
try:
    result = await client.subscriptions.subscribe(
        "jane@example.com",
        list_ids=["list_id"],
        topic_ids=["topic_id"],
        double_opt_in=True,
        redirect_url="https://yourapp.com/newsletter/confirmed",
        consent={"ip": "203.0.113.24", "user_agent": "Mozilla/5.0 ...", "text": "Yes, send me the monthly newsletter."},
    )
    print(result.status, result.requires_confirmation, result.confirmation_sent)
except NorthRelayError as error:
    if error.details.get("code") == "RECIPIENT_UNSUBSCRIBED":
        ...  # retry with resubscribe=True only after fresh consent
    else:
        raise

# Preference page: read, then save changes
status = await client.subscriptions.get("jane@example.com")
for topic in status.topics:
    print(topic.id, topic.label or topic.name, topic.subscribed)

status = await client.subscriptions.update_preferences(
    "jane@example.com",
    topics={"topic_id": False},
    lists={"list_id": True},
)

# Opt out from your server: scope "all" (default), "list" or "topic"
result = await client.subscriptions.unsubscribe("jane@example.com", scope="topic", topic_id="topic_id")
print(result.changed)

# Re-send the confirmation email for a PENDING contact
sent = await client.subscriptions.resend_confirmation("jane@example.com")
print(sent.expires_at)
MethodEndpointReturns
subscriptions.subscribe(email, *, first_name, last_name, phone, list_ids, topic_ids, tags, custom_fields, metadata, double_opt_in, confirmation_template_id, redirect_url, resubscribe, consent)POST /subscriptionsSubscribeResult
subscriptions.subscribe_request(request)Same, with a prepared SubscribeRequestSubscribeResult
subscriptions.unsubscribe(email, *, scope, list_id, topic_id, reason)POST /subscriptions/unsubscribeUnsubscribeResult
subscriptions.get(email)GET /subscriptions/{email}SubscriptionStatus
subscriptions.update_preferences(email, *, topics, lists, unsubscribe_all, resubscribe, consent)PATCH /subscriptions/{email}SubscriptionStatus
subscriptions.resend_confirmation(email)POST /subscriptions/{email}/resend-confirmationResendConfirmationResult

consent takes a dict (ip, user_agent or userAgent, text) or a Consent model. Emails are URL-encoded in paths for you. unsubscribe raises ValueError when scope="list" has no list_id or scope="topic" has no topic_id; update_preferences raises ValueError when none of topics, lists or unsubscribe_all=True is given. Subscription writes are not retried automatically.

Contacts

contact = await client.contacts.create(
    {"email": "user@example.com", "first_name": "Jane", "last_name": "Doe", "tags": ["trial"]}
)
page = await client.contacts.list(status="ACTIVE", tag="trial", limit=50)
contact = await client.contacts.update(contact.id, first_name="Janet")
await client.contacts.add_tags(contact.id, ["vip"])
await client.contacts.remove_tag(contact.id, "trial")
await client.contacts.bulk_upsert([{"email": "a@example.com"}, {"email": "b@example.com"}], skip_duplicates=True)
await client.contacts.bulk_delete(["contact_id_1", "contact_id_2"])
await client.contacts.delete(contact.id)

contacts.create() does not record consent or fire webhooks; use subscriptions.subscribe() when a person signs up. bulk_upsert creates new contacts and skips existing ones; it does not update them.

Lists and members

lst = await client.contacts.create_list("Product updates", topic_id="topic_id")  # tag with a topic

added = await client.contacts.add_to_list(lst.id, emails=["jane@example.com", "new@example.com"], create_missing=True)
print(added.added, added.already_members, added.not_found, added.blocked)

members = await client.contacts.list_members(lst.id, filter="active", limit=100)
print(members.counts, members.total, members.has_more)

await client.contacts.remove_from_list(lst.id, emails=["jane@example.com"])
MethodEndpoint
contacts.list_lists(*, page, limit, type, search, archived)GET /contacts/lists
contacts.get_list(id) / create_list(name, description, *, type, topic_id, tracking_enabled, segment_rules)/contacts/lists
contacts.update_list(id, name, description, *, topic_id, tracking_enabled, is_archived)PATCH /contacts/lists/{id} (topic_id=None removes the tag)
contacts.delete_list(id)DELETE /contacts/lists/{id} (archives)
contacts.list_members(id, *, page, limit, filter) (alias get_list_members)GET /contacts/lists/{id}/members
contacts.add_to_list(id, contact_ids=None, *, emails, create_missing)POST /contacts/lists/{id}/members
contacts.remove_from_list(id, contact_ids=None, *, emails)DELETE /contacts/lists/{id}/members

remove_from_list is the list owner removing someone (the membership is deleted). To record that a recipient asked to leave, use subscriptions.unsubscribe(email, scope="list", list_id=...).

CSV import

result = await client.contacts.import_csv(
    "subscribers.csv",                     # path, or the CSV content as bytes
    mappings=[{"csvColumn": "Email Address", "field": "email"}, {"csvColumn": "First", "field": "firstName"}],
    list_id="list_id",
    tags=["imported", "spring-2026"],
)
print(result.imported, result.skipped, result.added_to_list, result.errors)

When mappings is omitted, columns named like email, first name, last name and phone are mapped automatically. file_name sets the upload name when you pass bytes.

Topics (suppression groups)

client.topics is an alias of client.suppression_groups. A topic member is an address that opted out of that topic. Topic methods return the API response as a dict.

topic = await client.topics.create("product-updates", "Release notes")
topic_id = topic["data"]["id"]

await client.topics.update(
    topic_id,
    public_label="Product updates",
    public_description="New features, about twice a month.",
    is_published_on_unsub=True,
)

published = await client.topics.list(published=True)
opt_outs = await client.topics.list_members(topic_id, page=1, limit=25)
await client.topics.add_member(topic_id, "jane@example.com")     # opt out
await client.topics.remove_member(topic_id, "jane@example.com")  # opt back in

The member methods only edit the opt-out rows. For a recipient's own choice, prefer subscriptions.unsubscribe(..., scope="topic") or update_preferences, which also update tagged lists and fire webhooks.

Signup forms

Form methods return the data object of the API response as a dict.

created = await client.forms.create(list_id="list_id", name="Newsletter", slug="newsletter")
form_id = created["form"]["id"]

await client.forms.update(form_id, status="PUBLISHED", require_confirmation=True, success_redirect_url="https://yourapp.com/thanks")
await client.forms.set_fields(form_id, [
    {"fieldKey": "email", "label": "Email", "type": "EMAIL", "required": True, "bindsToContact": "EMAIL"},
    {"fieldKey": "first_name", "label": "First name", "type": "TEXT", "bindsToContact": "FIRST_NAME"},
])
forms = await client.forms.list(limit=20)
await client.forms.delete(form_id)  # archives the form

forms.submit(form_id, values) posts answers keyed by fieldKey to a published form. From your own backend, subscriptions.subscribe() is usually the better choice.

Webhooks

from northrelay import CreateWebhookRequest, UpdateWebhookRequest, WebhookEventType

webhook = await client.webhooks.create(
    CreateWebhookRequest(
        url="https://yourapp.com/webhooks/northrelay",
        events=[WebhookEventType.CONTACT_UNSUBSCRIBED, WebhookEventType.LIST_MEMBER_REMOVED, "email.bounced"],
        description="Newsletter sync",
    )
)
print(webhook.secret)  # shown once: store it

await client.webhooks.update(webhook.id, UpdateWebhookRequest(active=False))
new_secret = await client.webhooks.rotate_secret(webhook.id)
await client.webhooks.delete(webhook.id)

WEBHOOK_EVENT_TYPES lists every event name the API accepts.

Suppressions

await client.suppressions.add("optout@example.com")              # reason defaults to "Manual"
await client.suppressions.add("bounced@example.com", reason="Bounce")
status = await client.suppressions.check("optout@example.com")   # 404 NotFoundError when not suppressed
await client.suppressions.remove("optout@example.com")
page = await client.suppressions.list()

Emails are URL-encoded in paths for you. reason is one of Bounce, Complaint, Unsubscribe or Manual. To record a recipient's own opt-out, prefer subscriptions.unsubscribe().

Templates and Brands (Studio)

Every template is a Studio design. Dashboard templates use the application key "account"; a product with an nr_app_ credential uses its own key.

page = (await client.designs.list_page(application_key="account", search="welcome"))["data"]
design = (await client.designs.get(page["items"][0]["id"]))["data"]
await client.designs.preview(design["id"], sample=True, brand_id=other_brand_id)
await client.designs.publish(design["id"], design["revision"])
await client.designs.send(design["id"], to=[{"email": "ana@example.com"}],
                          variables={"first_name": "Ana"}, idempotency_key="welcome-ana-1")
usage = (await client.designs.usage(design["id"]))["data"]

asset = (await client.designs.upload_asset(image_base64))["data"]   # PNG, JPEG or WebP
brand = (await client.designs.create_brand(theme={"name": "Acme", "logoUrl": asset["url"]}))["data"]
await client.designs.set_default_brand(brand["id"])
affected = (await client.designs.brand_usage(brand["id"]))["data"]

templates and brand_theme are deprecated (sunset 30 April 2027). Template ids are design ids, so sending with template_id keeps working.

Other Resources

AttributeMethods
emailssend, send_template, schedule, send_batch, validate
designsTemplates, releases, previews, sends, usage, deliveries, gallery, brands, tracking domains, assets, manifests (see above)
credentialsApplication credentials
templatesDeprecated: legacy template API (sunset 30 April 2027)
campaignscreate, list, get, update, delete, preview, submit, approve, reject, send, get_send_status
domainslist, get, create, verify, delete
api_keys, keysAPI key management and rotation
eventslist, get, get_by_message_id
analyticsquery, get_engagement_heatmap, get_geographic, get_providers, request_export, get_export
metricsget, get_summary
brand_themeDeprecated: use the brand methods on designs
subusers, identity, ip_pools, ips, inbound, adminAccount and infrastructure management

Error Handling

from northrelay import (
    AuthenticationError,
    NorthRelayError,
    NotFoundError,
    QuotaExceededError,
    RateLimitError,
    ScopeError,
    ServerError,
    ValidationError,
)

try:
    await client.subscriptions.subscribe("jane@example.com", list_ids=["list_id"])
except RateLimitError as e:
    print(f"Rate limited. Retry after {e.retry_after} seconds.")
except ValidationError as e:
    print("Validation failed:", e.message, e.errors)
except NotFoundError as e:
    print(e.details.get("code"), e.details.get("fix_action"))  # e.g. LIST_NOT_FOUND
except NorthRelayError as e:
    print(e.status_code, e.details.get("code"), e.details.get("fix_action"))

Every exception carries message, status_code and details; details holds the API's code, fix_action and docs_url.

ExceptionWhenExtra attributes
AuthenticationErrorMissing or invalid API key (401)—
ScopeError403, including SCOPE_INSUFFICIENT and FREE_TIER_CONTACT_LIMIT—
ValidationError400errors
NotFoundError404, including LIST_NOT_FOUND, TOPIC_NOT_FOUND, CONTACT_NOT_FOUND—
QuotaExceededErrorMonthly quota exceeded (429)quota_limit, quota_used
RateLimitErrorRate limit exceeded (429)retry_after
ServerError5xx—
NetworkErrorThe request did not reach the API—
NorthRelayErrorAny other status, including 409 RECIPIENT_UNSUBSCRIBED, RECIPIENT_SUPPRESSED, CONTACT_BLOCKED, NO_PENDING_CONFIRMATIONstatus_code, details

Automatic Retries

Reads are retried on ServerError, NetworkError and RateLimitError with a retry_after, using exponential backoff from retry_delay capped at max_retry_delay (or the retry_after value). Writes to contacts, lists, subscriptions, topics, forms and webhooks are not retried automatically.

Webhook Verification

NorthRelay signs each delivery with HMAC-SHA256 of the raw body, hex-encoded, in the X-NorthRelay-Signature header. Verify it with the standard library, then parse the body into the SDK's WebhookPayload model:

import hashlib
import hmac
import os

from fastapi import FastAPI, HTTPException, Request
from northrelay import WebhookPayload

app = FastAPI()
SECRET = os.environ["NORTHRELAY_WEBHOOK_SECRET"]


@app.post("/webhooks/northrelay")
async def northrelay_webhook(request: Request):
    raw = await request.body()
    expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-NorthRelay-Signature", "")):
        raise HTTPException(status_code=401, detail="Invalid signature")

    event = WebhookPayload.model_validate_json(raw)
    if event.event_type == "contact.unsubscribed":
        scope = event.details.get("scope")          # "all" | "list" | "topic"
        email = event.details.get("contactEmail")
        ...
    return {"received": True}

On this page