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:
| Event | Description | Trigger |
|---|---|---|
email.queued | Message accepted and queued for delivery | API, batch, scheduled or inbox send |
email.delivered | Email accepted by the recipient's mail server | Successful SMTP delivery |
email.bounced | Email rejected by the receiving server | Bounce reported by the relay |
email.deferred | Email temporarily rejected; will retry | 4xx SMTP response |
email.dropped | Message was not sent | A batch or scheduled send failed, or a duplicate was discarded |
email.received | Inbound message received | Inbound mail to a hosted mailbox |
email.opened | A person opened the email (bots filtered) | Tracking pixel loaded |
email.clicked | A person clicked a tracked link (bots filtered) | Tracked link followed |
contact.subscribed | Someone subscribed, or opted back into a topic | Signup form, Subscriptions API, list join, preference centre |
contact.confirmed | A double opt-in was confirmed | Recipient clicked the confirmation link |
contact.unsubscribed | Someone unsubscribed from everything, a list or a topic | Unsubscribe link, one-click, preference centre, Subscriptions API |
list.member_added | A contact joined a list | Subscriptions API, list member API, preference centre (signup forms report the list in contact.subscribed instead) |
list.member_removed | A contact left a list | Unsubscribe from a list or topic, or removal by the list owner |
auth.success, auth.failed | SMTP authentication events | Accepted 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 /webhooksExample 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 /webhooksRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS endpoint URL that will receive POST requests |
events | string[] | Yes | One or more event types |
description | string | No | Up 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
| Field | Type | Required | Description |
|---|---|---|---|
url | string | No | New endpoint URL |
events | string[] | No | Updated event subscriptions |
description | string | No | Up to 500 characters |
active | boolean | No | Enable 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}/testSends 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": { }
}| Field | Type | Description |
|---|---|---|
eventType | string | One of the event types. Also sent in the X-NorthRelay-Event header. |
messageId | string | For email.* delivery events, the message id. For contact, list and engagement events, a unique id for this event. |
timestamp | string | ISO 8601 time the event was generated |
recipient | string | Recipient (or contact) address, when there is one |
sender | string | Sender address (email.* delivery events) |
subject | string | Subject line (email.* delivery events) |
status | string | Delivery status, for example delivered or dropped |
smtpResponse | string | Response from the receiving server, when available |
bounceReason, bounceType | string | Bounce details on email.bounced and email.dropped, when available |
details | object | Event-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 field | Description |
|---|---|
contactId, email | The contact |
source | FORM, API, IMPORT, PREFERENCE_CENTER, PLATFORM_SIGNUP or DASHBOARD |
listIds | Lists the contact was asked to join (may be omitted or empty) |
topicIds | Topics the contact opted (back) into (may be omitted or empty) |
requiresConfirmation | true while a double opt-in is outstanding; wait for contact.confirmed |
status | Contact status after the change: PENDING or ACTIVE |
formId, listId, fields | Signup-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 field | Description |
|---|---|
contactEmail | The address that unsubscribed |
contactId | The contact, or null when the address is not a contact |
scope | all (no marketing mail at all), list or topic |
listId | The list left, for scope: "list" |
topicId | The topic opted out of, for scope: "topic" |
method | one_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 |
campaignId | The campaign the unsubscribe came from, when known |
reason | Free text, when given |
category | Legacy 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 field | Description |
|---|---|
email | Recipient address |
contactId, campaignId | Set for campaign mail |
trackingId | Set for API mail: the tracking id of the sent message |
url | The clicked link (email.clicked only) |
userAgent | Client user agent, or null |
engagementType | Always 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
- Extract the
X-NorthRelay-Signatureheader value - Compute HMAC-SHA256 of the raw request body using your webhook
secretas the key - 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}, 200Dashboard: 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:
| Attempt | Delay |
|---|---|
| 1st retry | 30 seconds |
| 2nd retry | 2 minutes |
| 3rd retry | 15 minutes |
| 4th retry | 1 hour |
| 5th retry | 4 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
- Respond quickly. Return
200immediately and process the event asynchronously. Do not perform heavy work in the request handler. - Handle duplicates. Deduplicate on
eventType,messageIdandrecipient. The same event may be delivered more than once, and a contact change can arrive before or after a relatedlist.*event; read the current state withGET /subscriptions/{email}when order matters. - Verify signatures. Always validate the
X-NorthRelay-Signatureheader. - Use HTTPS. Webhook URLs must use HTTPS. HTTP endpoints are rejected.
- Log failures. Monitor your webhook endpoint health in the dashboard.