NorthRelayNorthRelay
API Reference

Webhooks

Receive real-time email delivery event notifications via webhooks.

Webhooks

Webhook destinations must use HTTPS on port 443, without credentials in the URL, and resolve exclusively to public IP addresses. Private, loopback, link-local and reserved addresses are rejected on every delivery. Redirects are not followed; configure the final endpoint URL. Deliveries have a 10-second deadline. These rules apply to test deliveries too.

Webhooks deliver real-time HTTP POST notifications to your server when email events occur (delivery, bounce, open, click, etc.). Use webhooks to update your application state, trigger workflows, or monitor deliverability.

Tier requirement: Webhooks are available on Starter ($5/mo) and above: 1 endpoint on Starter, 3 on Micro, 10 on Pro, unlimited on Scale.

Event Types

Subscribe a webhook to any of these values in events:

EventDescriptionTrigger
email.queuedMessage accepted and queued for deliveryAPI, batch, scheduled or inbox send
email.deliveredEmail accepted by the recipient's mail serverSuccessful SMTP delivery
email.bouncedEmail rejected by the receiving serverBounce reported by the relay
email.deferredEmail temporarily rejected; will retry4xx SMTP response
email.droppedMessage was not sentA batch or scheduled send failed, or a duplicate was discarded
email.receivedInbound message receivedInbound mail to a hosted mailbox
email.openedA person opened the email (bots filtered)Tracking pixel loaded
email.clickedA person clicked a tracked link (bots filtered)Tracked link followed
contact.subscribedSomeone subscribed, or opted back into a topicSignup form, Subscriptions API, list join, preference centre
contact.confirmedA double opt-in was confirmedRecipient clicked the confirmation link
contact.unsubscribedSomeone unsubscribed from everything, a list or a topicUnsubscribe link, one-click, preference centre, Subscriptions API
list.member_addedA contact joined a listSubscriptions API, list member API, preference centre (signup forms report the list in contact.subscribed instead)
list.member_removedA contact left a listUnsubscribe from a list or topic, or removal by the list owner
auth.success, auth.failedSMTP authentication eventsAccepted in events; reserved for SMTP login notifications

Payloads for the contact, list and engagement events are documented in Contact, list and engagement events.


List Webhooks

Retrieve all configured webhook endpoints.

GET /webhooks

Example Request

curl https://app.northrelay.ca/api/v1/webhooks \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx"

Example Response

{
  "webhooks": [
    {
      "id": "cmg7w3bh00006qz8k9x8y7z6w",
      "url": "https://yourapp.com/webhooks/northrelay",
      "events": ["email.delivered", "email.bounced", "contact.unsubscribed"],
      "description": "Newsletter sync",
      "active": true,
      "secretPrefix": "whsec_AbC1De...",
      "totalDeliveries": 42,
      "createdAt": "2026-10-01T10:00:00.000Z",
      "updatedAt": "2026-10-01T10:00:00.000Z"
    }
  ]
}

The full secret is never listed; only secretPrefix.


Create Webhook

Register a new webhook endpoint.

POST /webhooks

Request Body

FieldTypeRequiredDescription
urlstringYesHTTPS endpoint URL that will receive POST requests
eventsstring[]YesOne or more event types
descriptionstringNoUp to 500 characters

Example Request

curl -X POST https://app.northrelay.ca/api/v1/webhooks \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/northrelay",
    "events": ["email.delivered", "email.bounced", "contact.unsubscribed"]
  }'

Example Response

{
  "webhook": {
    "id": "cmg7w3bh00006qz8k9x8y7z6w",
    "url": "https://yourapp.com/webhooks/northrelay",
    "events": ["email.delivered", "email.bounced", "contact.unsubscribed"],
    "description": null,
    "active": true,
    "createdAt": "2026-10-06T14:30:00.000Z",
    "updatedAt": "2026-10-06T14:30:00.000Z",
    "secretPrefix": "whsec_AbC1De..."
  },
  "secret": "whsec_xxxxxxxxxxxxxxxx",
  "message": "Webhook created successfully. Save the secret - it will not be shown again."
}

Save the secret value -- you will need it to verify webhook signatures. The secret is only returned in the create response.


Update Webhook

Update an existing webhook endpoint.

PATCH /webhooks/{id}

Request Body

FieldTypeRequiredDescription
urlstringNoNew endpoint URL
eventsstring[]NoUpdated event subscriptions
descriptionstringNoUp to 500 characters
activebooleanNoEnable or disable the webhook

Returns { "webhook": { ... }, "message": "Webhook updated successfully" }.

Example Request

curl -X PATCH https://app.northrelay.ca/api/v1/webhooks/cmg7w3bh00006qz8k9x8y7z6w \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["email.delivered", "email.bounced", "email.opened", "email.clicked", "contact.unsubscribed"],
    "active": true
  }'

Delete Webhook

Remove a webhook endpoint. No further events will be delivered.

DELETE /webhooks/{id}

Example Request

curl -X DELETE https://app.northrelay.ca/api/v1/webhooks/cmg7w3bh00006qz8k9x8y7z6w \
  -H "Authorization: Bearer nr_live_xxxxxxxxxxxx"

Example Response

{ "message": "Webhook deleted successfully" }

Get Webhook

GET /webhooks/{id}

Returns { "webhook": { ... }, "stats": { "totalDeliveries", "successfulDeliveries", "successRate" } }, with delivery stats for the last 24 hours.


Rotate Secret

POST /webhooks/{id}/rotate
{
  "id": "cmg7w3bh00006qz8k9x8y7z6w",
  "secret": "whsec_yyyyyyyyyyyyyyyy",
  "previousSecretValidUntil": "2026-10-06T15:30:00.000Z"
}

The new secret is shown once. For one hour, until previousSecretValidUntil, deliveries also carry X-NorthRelay-Signature-Previous, signed with the old secret, so you can deploy the new secret without dropping events.


Test Webhook

POST /webhooks/{id}/test

Sends a test event (body { "eventType": "<event type>" }, default email.delivered) to an active webhook and reports the result:

{
  "success": true,
  "statusCode": 200,
  "responseTime": 184,
  "deliveryId": "5d0c6a1e-2b3f-4c7d-9e8a-1f2b3c4d5e6f",
  "message": "Test delivered (200) in 184ms"
}

When the endpoint cannot be reached, success is false, statusCode is 0 and errorMessage says why.


SDK

const { webhook, secret } = await client.webhooks.create({
  url: 'https://yourapp.com/webhooks/northrelay',
  events: ['contact.unsubscribed', 'list.member_removed', 'email.bounced'],
});
const { webhooks } = await client.webhooks.list();
const { webhook: current, stats } = await client.webhooks.get(webhook.id);
await client.webhooks.update(webhook.id, { active: false });
const rotated = await client.webhooks.rotateSecret(webhook.id);   // { id, secret, previousSecretValidUntil }
const test = await client.webhooks.testDelivery(webhook.id);
await client.webhooks.delete(webhook.id);
from northrelay import CreateWebhookRequest, UpdateWebhookRequest

webhook = await client.webhooks.create(
    CreateWebhookRequest(url="https://yourapp.com/webhooks/northrelay", events=["contact.unsubscribed", "email.bounced"])
)
print(webhook.secret)  # shown once
await client.webhooks.update(webhook.id, UpdateWebhookRequest(active=False))
new_secret = await client.webhooks.rotate_secret(webhook.id)
await client.webhooks.delete(webhook.id)

See the TypeScript SDK and Python SDK for details.


Payload Format

Every webhook is an HTTP POST with a JSON body. All events share this envelope:

{
  "eventType": "email.delivered",
  "messageId": "<20261006143500.a1b2c3@yourdomain.com>",
  "timestamp": "2026-10-06T14:35:00.000Z",
  "recipient": "alice@example.com",
  "sender": "hello@yourdomain.com",
  "subject": "Your order has shipped",
  "status": "delivered",
  "smtpResponse": "250 2.0.0 OK",
  "details": { }
}
FieldTypeDescription
eventTypestringOne of the event types. Also sent in the X-NorthRelay-Event header.
messageIdstringFor email.* delivery events, the message id. For contact, list and engagement events, a unique id for this event.
timestampstringISO 8601 time the event was generated
recipientstringRecipient (or contact) address, when there is one
senderstringSender address (email.* delivery events)
subjectstringSubject line (email.* delivery events)
statusstringDelivery status, for example delivered or dropped
smtpResponsestringResponse from the receiving server, when available
bounceReason, bounceTypestringBounce details on email.bounced and email.dropped, when available
detailsobjectEvent-specific data. For delivery events this is the raw event from the mail server; for the events below its shape is documented.

Fields that do not apply to an event are omitted. Each request also carries X-NorthRelay-Delivery (a unique id per delivery attempt) and X-NorthRelay-Signature.


Contact, List and Engagement Events

These events let you keep your own application in sync with subscriptions and engagement. They fire for every path that changes a subscription: hosted signup forms, the Subscriptions API, list member changes, unsubscribe links, RFC 8058 one-click unsubscribes and the hosted preference centre. CSV imports and direct contact edits (POST or PATCH /contacts) do not fire them.

contact.subscribed

Someone subscribed, joined lists through a subscribe call, or opted back into a topic.

{
  "eventType": "contact.subscribed",
  "messageId": "6f1c2a9e-4b7d-4f0e-9a51-2d3c8e7b1a40",
  "timestamp": "2026-10-06T14:02:11.000Z",
  "recipient": "jane@example.com",
  "details": {
    "contactId": "cmg7c0n1t0001qz8k2v5x9abc",
    "email": "jane@example.com",
    "source": "API",
    "listIds": ["cmg7l1st00002qz8kfe4w1xyz"],
    "topicIds": ["cmg7t0p1c0003qz8k7h2d4pqr"],
    "requiresConfirmation": true,
    "status": "PENDING"
  }
}
details fieldDescription
contactId, emailThe contact
sourceFORM, API, IMPORT, PREFERENCE_CENTER, PLATFORM_SIGNUP or DASHBOARD
listIdsLists the contact was asked to join (may be omitted or empty)
topicIdsTopics the contact opted (back) into (may be omitted or empty)
requiresConfirmationtrue while a double opt-in is outstanding; wait for contact.confirmed
statusContact status after the change: PENDING or ACTIVE
formId, listId, fieldsSignup-form submissions only: the form, its list (kept for older consumers) and the submitted fields

contact.confirmed

The recipient clicked the double opt-in link.

{
  "eventType": "contact.confirmed",
  "messageId": "0b8e3f52-1c6a-4d2e-8f7a-9e4b5c6d7a81",
  "timestamp": "2026-10-06T14:05:40.000Z",
  "recipient": "jane@example.com",
  "details": {
    "contactId": "cmg7c0n1t0001qz8k2v5x9abc",
    "email": "jane@example.com",
    "formId": null,
    "listIds": ["cmg7l1st00002qz8kfe4w1xyz"],
    "topicIds": ["cmg7t0p1c0003qz8k7h2d4pqr"],
    "confirmedAt": "2026-10-06T14:05:40.000Z"
  }
}

formId is null when the subscription came from the Subscriptions API.

contact.unsubscribed

The recipient unsubscribed from everything, from one list, or from one topic. Fires even when the recipient is not one of your contacts (for example an unsubscribe from API-sent marketing mail).

{
  "eventType": "contact.unsubscribed",
  "messageId": "a4d9c7e1-5f2b-4c8a-b3e6-7d1f0a9c2e55",
  "timestamp": "2026-10-07T09:12:03.000Z",
  "recipient": "jane@example.com",
  "details": {
    "category": "cmg7t0p1c0003qz8k7h2d4pqr",
    "contactEmail": "jane@example.com",
    "contactId": "cmg7c0n1t0001qz8k2v5x9abc",
    "scope": "topic",
    "listId": null,
    "topicId": "cmg7t0p1c0003qz8k7h2d4pqr",
    "method": "preference_center",
    "campaignId": null,
    "reason": null
  }
}
details fieldDescription
contactEmailThe address that unsubscribed
contactIdThe contact, or null when the address is not a contact
scopeall (no marketing mail at all), list or topic
listIdThe list left, for scope: "list"
topicIdThe topic opted out of, for scope: "topic"
methodone_click (mailbox provider button, or the button on NorthRelay's hosted unsubscribe page), link (your own unsubscribe page via POST /unsubscribe), preference_center, api, dashboard, platform_settings or complaint
campaignIdThe campaign the unsubscribe came from, when known
reasonFree text, when given
categoryLegacy alias of topicId, kept for existing consumers

list.member_added and list.member_removed

A contact joined or left a list. list.member_removed fires both when a recipient unsubscribes from a list (or from a topic the list is tagged with) and when you remove a member.

{
  "eventType": "list.member_removed",
  "messageId": "c2e7f1a0-8d3b-4a6c-9e5f-1b2d3c4e5f60",
  "timestamp": "2026-10-07T09:12:03.000Z",
  "recipient": "jane@example.com",
  "details": {
    "listId": "cmg7l1st00002qz8kfe4w1xyz",
    "contactId": "cmg7c0n1t0001qz8k2v5x9abc",
    "email": "jane@example.com",
    "source": "UNSUBSCRIBE"
  }
}

source is the subscription source (FORM, API, IMPORT, PREFERENCE_CENTER, PLATFORM_SIGNUP, DASHBOARD), or UNSUBSCRIBE when the recipient left by unsubscribing. Owner removals through the API use API.

email.opened and email.clicked

A person opened a message or clicked a tracked link. Opens and clicks classified as bots (security scanners, link prefetchers, privacy proxies) are not sent. They fire for campaign mail and for API mail with tracking: marketing-stream API mail is tracked by default (opt out with track: false), transactional API mail only with track: true.

{
  "eventType": "email.clicked",
  "messageId": "e9f8a7b6-c5d4-4e3f-a2b1-0c9d8e7f6a5b",
  "timestamp": "2026-10-07T10:20:45.000Z",
  "recipient": "jane@example.com",
  "details": {
    "email": "jane@example.com",
    "contactId": null,
    "campaignId": null,
    "trackingId": "3f6b2c1e-9a8d-4e7f-b6a5-c4d3e2f1a0b9",
    "url": "https://yourapp.com/pricing",
    "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
    "engagementType": "HUMAN"
  }
}
details fieldDescription
emailRecipient address
contactId, campaignIdSet for campaign mail
trackingIdSet for API mail: the tracking id of the sent message
urlThe clicked link (email.clicked only)
userAgentClient user agent, or null
engagementTypeAlways HUMAN

For API mail, email.opened fires once per message and recipient; repeat opens are not sent. Clicks fire every time. For campaign mail every human open is sent.


Signature Verification

Every webhook request includes an X-NorthRelay-Signature header containing an HMAC-SHA256 signature computed from the raw request body and your webhook secret.

Always verify signatures to ensure requests originate from NorthRelay and have not been tampered with.

Verification Algorithm

  1. Extract the X-NorthRelay-Signature header value
  2. Compute HMAC-SHA256 of the raw request body using your webhook secret as the key
  3. Compare the computed signature with the header value using a timing-safe comparison

Node.js Example

import crypto from "crypto";

function verifyWebhookSignature(
  body: string,
  signature: string,
  secret: string
): boolean {
  const computed = crypto
    .createHmac("sha256", secret)
    .update(body, "utf8")
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(computed, "hex"),
    Buffer.from(signature, "hex")
  );
}

// In your webhook handler:
app.post("/webhooks/northrelay", (req, res) => {
  const signature = req.headers["x-northrelay-signature"] as string;
  const rawBody = req.body; // must be the raw string, not parsed JSON

  if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const event = JSON.parse(rawBody);
  // Process event...
  res.status(200).json({ received: true });
});

Python Example

import hmac
import hashlib

def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
    computed = hmac.new(
        secret.encode("utf-8"),
        body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(computed, signature)

# In your webhook handler (Flask):
@app.route("/webhooks/northrelay", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-NorthRelay-Signature")
    if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
        return {"error": "Invalid signature"}, 401

    event = request.get_json()
    # Process event...
    return {"received": True}, 200

Dashboard: usage and activity

Settings → Webhooks in the dashboard shows, for the last 7 or 30 days, how many deliveries each endpoint received, how many failed, when the last one happened and a per-day sparkline. Each endpoint carries a health badge computed the same way as GET /webhooks/{id}/health: healthy at 95 % success or better, degraded at 80 %, failing below that or after 10 consecutive failures.

Opening an endpoint shows its activity: deliveries and failures per day, the breakdown by event type, the latest attempts with their HTTP status, latency and the first 200 characters of a failed response, and the endpoint's lifecycle (created, edited, enabled, disabled, secret rotated). A failed attempt can be retried from there; the endpoint then receives a webhook.retry event whose details name the original delivery, because payloads are not stored. Test sends a sample event of a type the endpoint subscribes to and records the attempt like any other delivery.

Every attempt counts, including tests and retries. Deliveries made before October 2026 have no recorded event type and appear as "unknown" in the breakdown. Attempts are kept for 90 days; an endpoint's history is also deleted with it.

Failure settings

Each endpoint carries what should happen when deliveries keep failing, set from the dashboard dialog ("When deliveries fail") or from PATCH /webhooks/{id}/failure-settings (enabled, notifyEmails, failureThreshold, autoDisableAfter):

  • Notify sends one email per streak when the consecutive failures reach the threshold (default 5), to up to five addresses or, when none is set, to the account owner.
  • Auto-disable turns the endpoint off once the streak reaches the given count (0, the default, never does). The lifecycle shows "Disabled automatically after N failed deliveries" and, with notifications on, the same addresses are told. Enable the endpoint again from its menu once it answers.

Retry Policy

If your endpoint does not respond with a 2xx status code within 10 seconds, NorthRelay retries the delivery:

AttemptDelay
1st retry30 seconds
2nd retry2 minutes
3rd retry15 minutes
4th retry1 hour
5th retry4 hours

After 5 failed retries, the event is marked as failed. Every attempt is visible in Settings → Webhooks (the endpoint's activity) for 90 days, and a failed one can be retried from there.

Best Practices

  1. Respond quickly. Return 200 immediately and process the event asynchronously. Do not perform heavy work in the request handler.
  2. Handle duplicates. Deduplicate on eventType, messageId and recipient. The same event may be delivered more than once, and a contact change can arrive before or after a related list.* event; read the current state with GET /subscriptions/{email} when order matters.
  3. Verify signatures. Always validate the X-NorthRelay-Signature header.
  4. Use HTTPS. Webhook URLs must use HTTPS. HTTP endpoints are rejected.
  5. Log failures. Monitor your webhook endpoint health in the dashboard.

On this page