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 northrelayQuick 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,
)| Option | Type | Default | Description |
|---|---|---|---|
api_key | str | required | Your NorthRelay API key |
base_url | str | https://app.northrelay.ca | API base URL |
timeout | float | 30.0 | Request timeout in seconds |
max_retries | int | 3 | Max attempts for retryable reads |
retry_delay | float | 1.0 | Initial retry delay in seconds |
max_retry_delay | float | 10.0 | Cap 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)| Method | Endpoint | Returns |
|---|---|---|
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 /subscriptions | SubscribeResult |
subscriptions.subscribe_request(request) | Same, with a prepared SubscribeRequest | SubscribeResult |
subscriptions.unsubscribe(email, *, scope, list_id, topic_id, reason) | POST /subscriptions/unsubscribe | UnsubscribeResult |
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-confirmation | ResendConfirmationResult |
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"])| Method | Endpoint |
|---|---|
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 inThe 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 formforms.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
| Attribute | Methods |
|---|---|
emails | send, send_template, schedule, send_batch, validate |
designs | Templates, releases, previews, sends, usage, deliveries, gallery, brands, tracking domains, assets, manifests (see above) |
credentials | Application credentials |
templates | Deprecated: legacy template API (sunset 30 April 2027) |
campaigns | create, list, get, update, delete, preview, submit, approve, reject, send, get_send_status |
domains | list, get, create, verify, delete |
api_keys, keys | API key management and rotation |
events | list, get, get_by_message_id |
analytics | query, get_engagement_heatmap, get_geographic, get_providers, request_export, get_export |
metrics | get, get_summary |
brand_theme | Deprecated: use the brand methods on designs |
subusers, identity, ip_pools, ips, inbound, admin | Account 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.
| Exception | When | Extra attributes |
|---|---|---|
AuthenticationError | Missing or invalid API key (401) | — |
ScopeError | 403, including SCOPE_INSUFFICIENT and FREE_TIER_CONTACT_LIMIT | — |
ValidationError | 400 | errors |
NotFoundError | 404, including LIST_NOT_FOUND, TOPIC_NOT_FOUND, CONTACT_NOT_FOUND | — |
QuotaExceededError | Monthly quota exceeded (429) | quota_limit, quota_used |
RateLimitError | Rate limit exceeded (429) | retry_after |
ServerError | 5xx | — |
NetworkError | The request did not reach the API | — |
NorthRelayError | Any other status, including 409 RECIPIENT_UNSUBSCRIBED, RECIPIENT_SUPPRESSED, CONTACT_BLOCKED, NO_PENDING_CONFIRMATION | status_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}Related
- TypeScript SDK
- MCP Server for AI agent integration
- Mailing Lists guide
- API Reference for raw HTTP API details
TypeScript SDK
Official NorthRelay TypeScript/Node.js client library for sending email, managing contacts, mailing lists and subscriptions, and verifying webhooks
MCP Server
NorthRelay MCP Server for AI agent integration with Claude Code, Claude Desktop, Codex, Cursor, Windsurf, and other MCP-compatible clients