NorthRelayNorthRelay
SDKs

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/sdk
yarn add @northrelay/sdk
pnpm add @northrelay/sdk

Quick 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)
});
OptionTypeDefaultDescription
apiKeystringrequiredYour NorthRelay API key (nr_live_, nr_test_ or nr_app_)
baseUrlstringhttps://app.northrelay.caAPI base URL (HTTPS, no credentials in the URL)
timeoutnumber30000Request timeout in milliseconds
maxRetriesnumber3Max retry attempts for retryable failures
retryDelaynumber1000Base 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');
MethodEndpointReturns data
subscriptions.subscribe(request)POST /subscriptionsSubscribeResult
subscriptions.unsubscribe(request)POST /subscriptions/unsubscribeUnsubscribeResult
subscriptions.get(email)GET /subscriptions/{email}SubscriptionStatus
subscriptions.updatePreferences(email, request)PATCH /subscriptions/{email}SubscriptionStatus
subscriptions.resendConfirmation(email)POST /subscriptions/{email}/resend-confirmationResendConfirmationResult

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'] });
MethodEndpoint
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 in

The 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 publish

templates and brandTheme are deprecated (sunset 30 April 2027). Template ids are design ids, so emails.send({ templateId }) keeps working.

Other Resources

PropertyMethods
emailssend, sendTemplate, schedule, cancel, validate, sendBatch, getEvents
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, getSendStatus
domainslist, get, create, verify, delete
apiKeys, keysAPI key management and rotation
eventslist, get, getByMessageId
analyticsquery, getEngagementHeatmap, getGeographic, getProviders, requestExport, getExport
metricsget, getSummary
brandThemeDeprecated: use the brand methods on designs
subusers, identity, ipPools, ips, inbox, adminAccount 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 classWhenExtra properties
AuthenticationErrorMissing or invalid API key (401)—
ScopeErrorThe key lacks the required scope (403 SCOPE_INSUFFICIENT)requiredScopes
ValidationErrorRequest failed validation (400 VALIDATION_ERROR)validationErrors
QuotaExceededErrorMonthly quota exceededquota
RateLimitErrorRate limit exceeded (429)retryAfter (seconds)
NotFoundErrorResource not found (404)—
ServerErrorNorthRelay server error (5xx)—
NetworkErrorThe request did not reach the API—
NorthRelayErrorAny 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); // boolean

TypeScript 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';

On this page