TypeScript SDK
Official NorthRelay TypeScript/Node.js client library for sending email, managing contacts, mailing lists and subscriptions, and verifying webhooks
TypeScript SDK
The official NorthRelay TypeScript SDK (@northrelay/sdk 1.9.0) wraps the NorthRelay REST API with typed requests and responses, automatic retries for safe calls, and webhook signature helpers.
Installation
npm install @northrelay/sdkyarn add @northrelay/sdkpnpm add @northrelay/sdkQuick Start
import { NorthRelayClient } from '@northrelay/sdk';
const client = new NorthRelayClient({
apiKey: process.env.NORTHRELAY_API_KEY!,
});
const response = await client.emails.send({
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>',
},
});
console.log('Queued:', response.data.messageId);Configuration
const client = new NorthRelayClient({
// Required
apiKey: 'nr_live_xxx',
// Optional — defaults shown
baseUrl: 'https://app.northrelay.ca',
timeout: 30000, // milliseconds
maxRetries: 3, // retries for retryable failures
retryDelay: 1000, // base delay in milliseconds (exponential backoff, capped at 10 s)
});| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | Your NorthRelay API key (nr_live_, nr_test_ or nr_app_) |
baseUrl | string | https://app.northrelay.ca | API base URL (HTTPS, no credentials in the URL) |
timeout | number | 30000 | Request timeout in milliseconds |
maxRetries | number | 3 | Max retry attempts for retryable failures |
retryDelay | number | 1000 | Base delay between retries in milliseconds |
Responses
Most methods resolve to the API's response envelope, { success: true, data }, so read results from data:
const { data } = await client.subscriptions.get('jane@example.com');Contact-list routes return { data } (and pagination for lists), and importCsv returns the import result directly. The return type of every method says which shape you get.
Mailing Lists and Subscriptions
See the Mailing Lists guide for the full integration and the Subscriptions API for request and response details.
Subscriptions
import { isSubscriptionError } from '@northrelay/sdk';
// Subscribe with double opt-in and consent evidence
try {
const { data } = await client.subscriptions.subscribe({
email: 'jane@example.com',
listIds: ['list_id'],
topicIds: ['topic_id'],
doubleOptIn: true,
redirectUrl: 'https://yourapp.com/newsletter/confirmed',
consent: { ip: '203.0.113.24', userAgent: 'Mozilla/5.0 ...', text: 'Yes, send me the monthly newsletter.' },
});
// data.status is 'PENDING' until the recipient confirms
} catch (error) {
if (isSubscriptionError(error, 'RECIPIENT_UNSUBSCRIBED')) {
// they unsubscribed earlier: retry with resubscribe: true only after fresh consent
} else {
throw error;
}
}
// Preference page: read, then save changes
const { data: status } = await client.subscriptions.get('jane@example.com');
await client.subscriptions.updatePreferences('jane@example.com', {
topics: { topic_id: false },
lists: { list_id: true },
});
// Opt out from your server: scope 'all' (default), 'list' or 'topic'
await client.subscriptions.unsubscribe({ email: 'jane@example.com', scope: 'list', listId: 'list_id' });
// Re-send the confirmation email for a PENDING contact
await client.subscriptions.resendConfirmation('jane@example.com');| Method | Endpoint | Returns data |
|---|---|---|
subscriptions.subscribe(request) | POST /subscriptions | SubscribeResult |
subscriptions.unsubscribe(request) | POST /subscriptions/unsubscribe | UnsubscribeResult |
subscriptions.get(email) | GET /subscriptions/{email} | SubscriptionStatus |
subscriptions.updatePreferences(email, request) | PATCH /subscriptions/{email} | SubscriptionStatus |
subscriptions.resendConfirmation(email) | POST /subscriptions/{email}/resend-confirmation | ResendConfirmationResult |
Emails are URL-encoded in paths for you. isSubscriptionError(error, code?) returns true for a NorthRelayError whose code is one of LIST_NOT_FOUND, TOPIC_NOT_FOUND, CONTACT_NOT_FOUND, CONTACT_BLOCKED, RECIPIENT_SUPPRESSED, RECIPIENT_UNSUBSCRIBED, NO_PENDING_CONFIRMATION, FREE_TIER_CONTACT_LIMIT or VALIDATION_ERROR (optionally a specific one). subscribe and resendConfirmation are never retried automatically, because a retry could send a second confirmation email.
Contacts
const { data: contact } = await client.contacts.create({
email: 'user@example.com',
firstName: 'Jane',
lastName: 'Doe',
tags: ['trial'],
metadata: { plan: 'trial' },
});
const page = await client.contacts.list({ status: 'ACTIVE', tag: 'trial', limit: 50 });
await client.contacts.update(contact.id, { firstName: 'Janet' });
await client.contacts.addTags(contact.id, ['vip']);
await client.contacts.removeTag(contact.id, 'trial');
await client.contacts.bulkCreate([{ email: 'a@example.com' }, { email: 'b@example.com' }], { skipDuplicates: true });
await client.contacts.bulkDelete(['contact_id_1', 'contact_id_2']);
await client.contacts.delete(contact.id);contacts.list() filters by status, tag, search, source, sortBy and sortOrder. It does not filter by list; use getListMembers(listId). contacts.create() does not record consent or fire webhooks; use subscriptions.subscribe() when a person signs up.
Lists and members
const { data: list } = await client.contacts.createList({
name: 'Product updates',
suppressionGroupId: 'topic_id', // tag the list with a topic
});
const added = await client.contacts.addListMembers(list.id, {
emails: ['jane@example.com', 'new@example.com'],
createMissing: true,
});
// added.data: { added, alreadyMembers, notFound, blocked }
const members = await client.contacts.getListMembers(list.id, { filter: 'active', page: 1, limit: 100 });
// members: { data, counts: { total, active, suppressed }, pagination }
await client.contacts.removeListMembers(list.id, { emails: ['jane@example.com'] });| Method | Endpoint |
|---|---|
contacts.listLists({ page, limit, type, isArchived, search }) | GET /contacts/lists |
contacts.getList(id) / createList(request) / updateList(id, request) / deleteList(id) | /contacts/lists/{id} (delete archives) |
contacts.getListMembers(id, { page, limit, filter }) | GET /contacts/lists/{id}/members |
contacts.addListMembers(id, { contactIds?, emails?, createMissing? }) | POST /contacts/lists/{id}/members |
contacts.removeListMembers(id, { contactIds?, emails? }) | DELETE /contacts/lists/{id}/members |
contacts.addToList(id, contactIdsOrInput) / removeFromList(id, contactIdsOrInput) | Shorthands that accept an array of contact ids |
removeListMembers is the list owner removing someone (the membership is deleted). To record that a recipient asked to leave, use subscriptions.unsubscribe({ email, scope: 'list', listId }).
CSV import
const result = await client.contacts.importCsv(csvTextOrBlob, {
mappings: [
{ csvColumn: 'Email Address', field: 'email' },
{ csvColumn: 'First', field: 'firstName' },
],
listId: 'list_id',
tags: ['imported', 'spring-2026'],
});
// result: { imported, skipped, addedToList, errors }importCsv accepts a Blob/File or the CSV text. mappings is required.
Topics (suppression groups)
A topic member is an address that opted out of that topic.
const { data: topic } = await client.suppressionGroups.create({ name: 'product-updates', description: 'Release notes' });
await client.suppressionGroups.update(topic.id, {
publicLabel: 'Product updates',
publicDescription: 'New features, about twice a month.',
isPublishedOnUnsub: true,
});
const published = await client.suppressionGroups.list({ published: true });
const optOuts = await client.suppressionGroups.listMembers(topic.id, { page: 1, limit: 25 });
await client.suppressionGroups.addMember(topic.id, 'jane@example.com'); // opt out
await client.suppressionGroups.removeMember(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', topicId }) or updatePreferences, which also update tagged lists and fire webhooks. bulkAddSuppressions(id, emails) and bulkRemoveSuppressions(id, emails) change up to 10,000 opt-outs at once.
Signup forms
const { data: { form } } = await client.forms.create({ listId: 'list_id', name: 'Newsletter', slug: 'newsletter' });
await client.forms.update(form.id, { status: 'PUBLISHED', requireConfirmation: true, successRedirectUrl: 'https://yourapp.com/thanks' });
await client.forms.setFields(form.id, [
{ fieldKey: 'email', label: 'Email', type: 'EMAIL', required: true, bindsToContact: 'EMAIL' },
{ fieldKey: 'first_name', label: 'First name', type: 'TEXT', bindsToContact: 'FIRST_NAME' },
]);
const forms = await client.forms.list({ limit: 20 });
await client.forms.archive(form.id);forms.submit(id, values) posts answers keyed by fieldKey to a published form. From your own backend, subscriptions.subscribe() is usually the better choice.
Webhooks
const { webhook, secret } = await client.webhooks.create({
url: 'https://yourapp.com/webhooks/northrelay',
events: ['contact.subscribed', 'contact.confirmed', 'contact.unsubscribed', 'list.member_removed', 'email.bounced'],
description: 'Newsletter sync',
});
// secret is returned only here: store it
const { webhooks } = await client.webhooks.list();
const { webhook: current, stats } = await client.webhooks.get(webhook.id);
await client.webhooks.update(webhook.id, { active: false }); // { webhook, message }
const rotated = await client.webhooks.rotateSecret(webhook.id); // { id, secret, previousSecretValidUntil }
const test = await client.webhooks.testDelivery(webhook.id); // { success, statusCode?, responseTime?, error? }
await client.webhooks.delete(webhook.id); // { message }The webhook routes return their own shapes rather than the { success, data } envelope, as shown above. create is not retried automatically. After rotateSecret, deliveries also carry X-NorthRelay-Signature-Previous (signed with the old secret) until previousSecretValidUntil, one hour later. events is typed as WebhookEventType[]. See Webhook verification for the receiving side.
Suppressions
await client.suppressions.add({ email: 'optout@example.com', reason: 'Manual' });
await client.suppressions.bulkAdd(['a@example.com', 'b@example.com']); // reason defaults to 'Manual'
await client.suppressions.bulkAdd(['bounced@example.com'], 'Bounce');
await client.suppressions.bulkRemove(['a@example.com']);
await client.suppressions.remove('optout@example.com'); // email is URL-encoded for you
const page = await client.suppressions.list({ page: 1, limit: 25 });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. Your dashboard templates use the application key "account" (ACCOUNT_APPLICATION); a product with an nr_app_ credential uses its own key.
import { NorthRelayClient, ACCOUNT_APPLICATION } from '@northrelay/sdk';
const nr = new NorthRelayClient({ apiKey: process.env.NORTHRELAY_API_KEY! });
// Templates
const { data: page } = await nr.designs.listPage({ applicationKey: ACCOUNT_APPLICATION, search: 'welcome' });
const { data: design } = await nr.designs.get(page.items[0].id);
await nr.designs.update(design.id, { ...design.draft, subject: 'Welcome, {{first_name}}' }, design.revision);
await nr.designs.preview(design.id, { sample: true, brandId: otherBrandId }); // try another brand
await nr.designs.publish(design.id, design.revision + 1);
await nr.designs.send(design.id, { to: [{ email: 'ana@example.com' }], variables: { first_name: 'Ana' } }, 'welcome-ana-1');
const { data: usage } = await nr.designs.usage(design.id); // campaigns, send counts
const { data: deliveries } = await nr.designs.deliveries(design.id); // design and API sends
// Gallery starters (asBlocks: edit in the visual editor)
const { data: catalog } = await nr.designs.catalog({ search: 'receipt' });
const { data: reviewed } = await nr.designs.inspectTemplate(catalog.gallery[0]);
await nr.designs.adoptTemplate({ ...reviewed.source, asBlocks: true });
// Brands and logos
const { data: asset } = await nr.designs.uploadAsset({ imageBase64 }); // PNG, JPEG or WebP
const { data: brand } = await nr.designs.createBrand({ theme: { name: 'Acme', logoUrl: asset.url, primaryColor: '#0f766e' } });
await nr.designs.setDefaultBrand(brand.id);
const { data: affected } = await nr.designs.brandUsage(brand.id); // templates needing a publishtemplates and brandTheme are deprecated (sunset 30 April 2027). Template ids are design ids, so emails.send({ templateId }) keeps working.
Other Resources
| Property | Methods |
|---|---|
emails | send, sendTemplate, schedule, cancel, validate, sendBatch, getEvents |
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, getSendStatus |
domains | list, get, create, verify, delete |
apiKeys, keys | API key management and rotation |
events | list, get, getByMessageId |
analytics | query, getEngagementHeatmap, getGeographic, getProviders, requestExport, getExport |
metrics | get, getSummary |
brandTheme | Deprecated: use the brand methods on designs |
subusers, identity, ipPools, ips, inbox, admin | Account and infrastructure management |
Error Handling
API failures are thrown as NorthRelayError or one of its subclasses. Every error carries code, statusCode, fixAction and docsUrl when the API supplied them.
import { NorthRelayError, RateLimitError, ValidationError, isSubscriptionError } from '@northrelay/sdk';
try {
await client.subscriptions.subscribe({ email: 'jane@example.com', listIds: ['list_id'] });
} catch (error) {
if (error instanceof RateLimitError) {
console.log(`Rate limited. Retry after ${error.retryAfter} seconds.`);
} else if (error instanceof ValidationError) {
console.log('Validation failed:', error.message, error.validationErrors);
} else if (isSubscriptionError(error)) {
console.log(error.code, error.fixAction); // e.g. RECIPIENT_SUPPRESSED
} else if (error instanceof NorthRelayError) {
console.error(error.code, error.statusCode, error.message);
} else {
throw error;
}
}| Error class | When | Extra properties |
|---|---|---|
AuthenticationError | Missing or invalid API key (401) | — |
ScopeError | The key lacks the required scope (403 SCOPE_INSUFFICIENT) | requiredScopes |
ValidationError | Request failed validation (400 VALIDATION_ERROR) | validationErrors |
QuotaExceededError | Monthly quota exceeded | quota |
RateLimitError | Rate limit exceeded (429) | retryAfter (seconds) |
NotFoundError | Resource not found (404) | — |
ServerError | NorthRelay server error (5xx) | — |
NetworkError | The request did not reach the API | — |
NorthRelayError | Any other API error, including the subscription codes (RECIPIENT_UNSUBSCRIBED, LIST_NOT_FOUND, ...) | code, statusCode, fixAction, docsUrl |
Automatic Retries
Read calls and idempotent writes are retried on network errors and HTTP 408, 429, 500, 502, 503 and 504, with exponential backoff from retryDelay (capped at 10 seconds) or the Retry-After header. Calls that could duplicate a side effect are not retried: subscriptions.subscribe, subscriptions.resendConfirmation, contacts.create, contacts.bulkCreate, contacts.importCsv, contacts.createList and the form create and submit calls.
Webhook Verification
NorthRelay signs each delivery with HMAC-SHA256 of the raw body in the X-NorthRelay-Signature header. Verify against the raw body, before parsing it.
import express from 'express';
import { parseWebhookPayload, verifyWebhookSignature } from '@northrelay/sdk';
const app = express();
app.post('/webhooks/northrelay', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
const signature = req.header('x-northrelay-signature') ?? '';
let event;
try {
// Throws when the signature is invalid; returns a typed WebhookPayload
event = parseWebhookPayload(raw, signature, process.env.NORTHRELAY_WEBHOOK_SECRET!);
} catch {
return res.status(401).json({ error: 'Invalid signature' });
}
switch (event.eventType) {
case 'contact.unsubscribed':
// event.details.scope is 'all' | 'list' | 'topic'
markOptedOut(event.details.contactEmail, event.details.scope, event.details.listId ?? event.details.topicId);
break;
case 'list.member_added':
syncMember(event.details.listId, event.details.email);
break;
case 'email.clicked':
recordClick(event.details.email, event.details.url);
break;
}
res.status(200).json({ received: true });
});
// Or only check the signature:
const ok = verifyWebhookSignature(rawBody, signatureHeader, secret); // booleanTypeScript Types
Request, response and webhook types are exported:
import type {
ClientConfig,
SendEmailRequest,
Contact,
ContactList,
SubscribeRequest,
SubscribeResult,
SubscriptionStatus,
UpdatePreferencesRequest,
SubscriptionErrorCode,
WebhookEventType,
WebhookPayload,
ContactUnsubscribedWebhook,
} from '@northrelay/sdk';Related
- Python SDK for Python integration
- MCP Server for AI agent integration
- Mailing Lists guide
- API Reference for raw HTTP API details