Notix
Guides

Get every delivery event at your own endpoint.

Every Notix webhook event, the payload and headers, signature verification in Node.js, retries and troubleshooting. Every endpoint named here is in the API reference.

Webhooks send an HTTP POST to your server when something happens in Notix, such as when an email is delivered, bounced, or clicked. This enables you to build real-time integrations and automate workflows.

Setting up webhooks

1. Create an endpoint

Create an endpoint on your server that accepts POST requests. It must:

  • Accept a JSON body
  • Answer with a 2xx status to acknowledge the event
  • Answer within 10 seconds

2. Add the webhook in the console

Open Webhooks in the Notix console and create a webhook:

  • Enter your endpoint URL
  • Choose the events you want
  • Copy the signing secret

3. Verify every signature

Always verify the signature so you know a request came from Notix. See Signature verification below.

Event types

Email events

EventDescription
email.queuedEmail has been queued for sending
email.sentEmail has been sent to the recipient's mail server
email.deliveredEmail was successfully delivered
email.delivery_delayedEmail delivery is being retried
email.bouncedEmail bounced (permanent or temporary)
email.rejectedEmail was rejected
email.rendering_failureEmail failed during template rendering
email.complainedRecipient marked email as spam
email.failedEmail failed to send
email.cancelledScheduled email was cancelled
email.suppressedEmail was suppressed (recipient on suppression list)
email.openedRecipient opened the email
email.clickedRecipient clicked a link in the email

Contact events

EventDescription
contact.createdNew contact was created
contact.updatedContact was updated
contact.deletedContact was deleted
contact.subscribedContact became subscribed
contact.unsubscribedContact became unsubscribed

Domain events

EventDescription
domain.createdNew domain was added
domain.verifiedDomain verification completed
domain.updatedDomain settings were updated
domain.deletedDomain was deleted

Verification events

EventDescription
verification.sentA verification code was queued for delivery
verification.verifiedA submitted code matched
verification.failedThe code ran out of attempts or expired at check time
verification.refusedRisk scoring refused the request
verification.deliveredAn SMS verification was confirmed delivered by the provider

Every verification payload carries channel ("email" or "sms"). Older email-only payloads may omit it.

SMS events

EventDescription
sms.sentThe provider accepted the message, status queued or sent
sms.deliveredThe provider confirmed delivery
sms.failedThe message failed or was rejected, status failed or rejected

Inbound email events

EventDescription
email.receivedAn inbox received a message that was not caught as spam

Journey events

EventDescription
journey.enrolledA contact was enrolled into a journey
journey.completedA run finished its last step
journey.exitedA run left the journey before the end

Campaign events

EventDescription
campaign.startedThe first batch of a campaign is queued
campaign.completedThe last email of a campaign is processed

Deliverability events

EventDescription
deliverability.blockedA send, schedule, activation or edit was refused by the check

Webhook payload

Each webhook request includes a JSON payload with the following structure. See Event data details for details on the data field for each event type.

json
{
  "id": "call_abc123",
  "type": "email.delivered",
  "version": "2026-01-18",
  "createdAt": "2024-01-15T10:30:00.000Z",
  "teamId": 123,
  "data": {
    "id": "email_123",
    "status": "DELIVERED",
    "from": "sender@example.com",
    "to": ["recipient@example.com"],
    "subject": "Welcome!",
    "occurredAt": "2024-01-15T10:30:00Z"
  },
  "attempt": 1
}

Payload fields

FieldDescription
idUnique identifier for this webhook call
typeThe event type (e.g., email.delivered)
versionAPI version for the payload format
createdAtWhen the event was created
teamIdYour team ID
dataEvent-specific data (varies by event type)
attemptDelivery attempt number (1-6)

Request headers

Each webhook request includes the following headers:

HeaderDescription
X-Notix-SignatureHMAC-SHA256 signature for verification
X-Notix-TimestampUnix timestamp in milliseconds
X-Notix-EventEvent type
X-Notix-CallUnique webhook call ID
X-Notix-Retrytrue if this is a retry attempt

Signature verification

Always verify webhook signatures to ensure requests are authentic. The signature is computed as:

HMAC-SHA256(secret, "${timestamp}.${rawBody}")

With the Node.js SDK

terminal
npm install notix-js
# or: yarn add notix-js / pnpm add notix-js / bun add notix-js

Next.js App Router

typescript
import { Notix } from "notix-js";

const notix = new Notix(process.env.NOTIX_API_KEY);
const webhooks = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!);

export async function POST(request: Request) {
  try {
    const rawBody = await request.text();
    const event = webhooks.constructEvent(rawBody, {
      headers: request.headers,
    });

    switch (event.type) {
      case "email.delivered":
        console.log("Email delivered to:", event.data.to);
        break;
      case "email.bounced":
        console.log("Email bounced:", event.data.id);
        break;
      case "email.opened":
        console.log("Email opened:", event.data.id);
        break;
    }

    return new Response("ok");
  } catch (error) {
    console.error("Webhook error:", error);
    return new Response((error as Error).message, { status: 400 });
  }
}

Express

typescript
import express from "express";
import { Webhooks } from "notix-js";

const webhooks = new Webhooks(process.env.NOTIX_WEBHOOK_SECRET!);

const app = express();

// Important: Use raw body parser for webhook routes
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = webhooks.constructEvent(req.body, {
      headers: req.headers,
    });

    switch (event.type) {
      case "email.delivered":
        console.log("Email delivered to:", event.data.to);
        break;
      case "email.bounced":
        console.log("Email bounced:", event.data.id);
        break;
    }

    res.status(200).send("ok");
  } catch (error) {
    console.error("Webhook error:", error);
    res.status(400).send((error as Error).message);
  }
});

app.listen(3000);

Verification only

If you only need to verify the signature without parsing:

typescript
const isValid = webhooks.verify(rawBody, { headers: request.headers });

if (!isValid) {
  return new Response("Invalid signature", { status: 401 });
}

Manual verification

If you prefer to verify manually without the SDK:

typescript
import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhook(
  secret: string,
  rawBody: string,
  signature: string,
  timestamp: string,
): boolean {
  const expectedSignature = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const expected = Buffer.from(`v1=${expectedSignature}`, "utf8");
  const received = Buffer.from(signature, "utf8");

  if (expected.length !== received.length) {
    return false;
  }

  return timingSafeEqual(expected, received);
}

// Usage
const signature = request.headers.get("X-Notix-Signature");
const timestamp = request.headers.get("X-Notix-Timestamp");

const isValid = verifyWebhook(secret, rawBody, signature, timestamp);

Retry behavior

If your endpoint doesn't return a 2xx response, Notix will retry delivery with exponential backoff:

AttemptDelay
1Immediate
2~5 seconds
3~10 seconds
4~20 seconds
5~40 seconds
6~80 seconds

After 6 failed attempts, the webhook call is marked as failed.

Auto-disable: if your endpoint fails 30 calls in a row, the webhook is disabled so it stops failing. Fix the endpoint, then turn the webhook back on in the console.

Best practices

Respond quickly

Return a 2xx as soon as possible and do slow work afterwards. Requests time out after 10 seconds.

Handle duplicates

Use the id field in the payload to deduplicate. In rare cases the same event is delivered more than once.

Verify signatures

Always check the X-Notix-Signature header, so you know a request came from Notix and was not changed.

Check timestamps

The SDK rejects signatures older than 5 minutes by default, which stops replayed requests.

Use HTTPS

Use an HTTPS endpoint in production so event data is encrypted in transit.

Testing webhooks

You can send a test event from the console to verify your endpoint is working correctly:

  1. Open Webhooks in the console
  2. Open your webhook
  3. Send a test event

The test event will have type webhook.test with the following payload:

json
{
  "test": true,
  "webhookId": "wh_abc123",
  "sentAt": "2024-01-15T10:30:00.000Z"
}

Troubleshooting

Events are not arriving

  • Check that the endpoint URL is correct and reachable from the internet
  • Check that the endpoint answers with a 2xx status
  • Check that the webhook is active in the console
  • Check whether it was disabled after 30 failures in a row

Signature verification fails

  • Verify the raw request body, not parsed and re-serialised JSON
  • Use the signing secret of this webhook
  • Check that the timestamp is within the 5 minute window
  • Compute HMAC-SHA256(secret, "${timestamp}.${rawBody}")

The webhook was disabled

After 30 failed calls in a row a webhook is disabled. Fix the endpoint, then turn the webhook back on in the console. The failure count resets on the next successful delivery.

Event data details

This section documents the data field structure for each event type.

Email events

Most email events share a common base structure:

typescript
{
  id: string;              // Email ID
  status: string;          // Email status (e.g., "DELIVERED", "BOUNCED")
  from: string;            // Sender email address
  to: string[];            // Recipient email addresses
  occurredAt: string;      // ISO 8601 timestamp
  subject?: string;        // Email subject
  campaignId?: string;     // Campaign ID (if from a campaign)
  contactId?: string;      // Contact ID (if sent to a contact)
  domainId?: number;       // Domain ID
  templateId?: string;     // Template ID (if using a template)
  metadata?: object;       // Custom metadata you attached to the email
}

email.bounced

Includes additional bounce details:

typescript
{
  // ... base email fields
  bounce: {
    type: "Transient" | "Permanent" | "Undetermined";
    subType: "General" | "NoEmail" | "Suppressed" | "OnAccountSuppressionList"
           | "MailboxFull" | "MessageTooLarge" | "ContentRejected" | "AttachmentRejected";
    message?: string;      // Bounce message from the mail server
  }
}

email.failed

Includes failure reason:

typescript
{
  // ... base email fields
  failed: {
    reason: string; // Failure reason
  }
}

email.suppressed

Includes suppression details:

typescript
{
  // ... base email fields
  suppression: {
    type: "Bounce" | "Complaint" | "Manual";
    reason: string;        // Why the email was suppressed
    source?: string;       // Source of the suppression
  }
}

email.opened

Includes open tracking details:

typescript
{
  // ... base email fields
  open: {
    timestamp: string;     // When the email was opened
    userAgent?: string;    // Browser/client user agent
    ip?: string;           // IP address
    platform?: string;     // Detected platform
  }
}

email.clicked

Includes click tracking details:

typescript
{
  // ... base email fields
  click: {
    timestamp: string;     // When the link was clicked
    url: string;           // The clicked URL
    userAgent?: string;    // Browser/client user agent
    ip?: string;           // IP address
    platform?: string;     // Detected platform
  }
}

Contact events

contact.created, contact.updated and contact.deleted include the whole contact:

typescript
{
  id: string;              // Contact ID
  email: string;           // Contact email address
  contactBookId: string;   // Contact book ID
  subscribed: boolean;     // Subscription status
  properties: object;      // Custom properties
  firstName?: string;      // First name
  lastName?: string;       // Last name
  createdAt: string;       // ISO 8601 timestamp
  updatedAt: string;       // ISO 8601 timestamp
}

Contact subscription events

contact.subscribed fires when a contact is created already subscribed, and when a double opt-in confirmation subscribes one. source says which.

json
{
  "contactId": "cnt_abc123",
  "contactBookId": "cb_abc123",
  "email": "alice@example.com",
  "source": "double_opt_in"
}

contact.unsubscribed fires on every unsubscribe path, and names the reason the contact row records.

json
{
  "contactId": "cnt_abc123",
  "contactBookId": "cb_abc123",
  "email": "alice@example.com",
  "reason": "UNSUBSCRIBED"
}

Verification events

verification.sent fires once the code email is queued. to carries the recipient's domain only, matching the redaction the delivered email gets.

json
{
  "id": "ver_abc123",
  "to": "example.com",
  "expiresAt": "2024-01-15T10:40:00.000Z",
  "risk": {
    "score": 20,
    "level": "MEDIUM",
    "reasons": ["role_account"]
  }
}

verification.verified fires when a submitted code matches. attempts counts every attempt made, including the one that verified the code.

json
{
  "id": "ver_abc123",
  "verifiedAt": "2024-01-15T10:33:00.000Z",
  "attempts": 2
}

verification.failed fires when a check finds the code out of attempts or past its expiry.

json
{
  "id": "ver_abc123",
  "reason": "max_attempts",
  "attempts": 5
}

verification.refused fires when risk scoring refuses the request. No code is generated and no email is queued.

json
{
  "id": "ver_abc123",
  "risk": {
    "score": 70,
    "level": "HIGH",
    "reasons": ["disposable_domain"]
  },
  "refusalCodes": ["disposable_domain"]
}

verification.delivered fires for channel: "sms" only, when the provider confirms delivery of the code.

json
{
  "id": "ver_abc123",
  "channel": "sms",
  "deliveredAt": "2024-01-15T10:33:05.000Z"
}

SMS events

sms.sent fires once the provider accepts the message. to is masked to the last four digits.

json
{
  "id": "sms_abc123",
  "to": "+234*******5678",
  "status": "sent",
  "segments": 1,
  "occurredAt": "2024-01-15T10:33:00.000Z"
}

sms.delivered fires when the provider confirms delivery.

json
{
  "id": "sms_abc123",
  "to": "+234*******5678",
  "status": "delivered",
  "segments": 1,
  "occurredAt": "2024-01-15T10:33:05.000Z"
}

sms.failed fires when the message failed after being sent, or was rejected before delivery. reason is a plain-language reason, never a provider code.

json
{
  "id": "sms_abc123",
  "to": "+234*******5678",
  "status": "failed",
  "segments": 1,
  "reason": {
    "code": "not_reachable",
    "message": "Recipient number not reachable."
  },
  "occurredAt": "2024-01-15T10:33:05.000Z"
}

Inbound email events

email.received fires when one of your inboxes receives a message that passed the spam and virus checks. strippedText is the new part of a reply without its quoted history, attachment links stay valid for 24 hours, and inReplyToEmailId is set when the message answers an email Notix sent.

json
{
  "messageId": "inm_8f2k1",
  "conversationId": "cnv_31ad9",
  "inbox": { "id": "ibx_supp", "name": "Support" },
  "from": "Chiamaka Obi <chiamaka@gmail.com>",
  "to": ["acme-support@inbound.usenotix.dev"],
  "cc": [],
  "subject": "Refund for double charge on order 4471",
  "text": "Hello, I was charged twice...",
  "html": "<p>Hello, I was charged twice...</p>",
  "strippedText": "Hello, I was charged twice...",
  "attachments": [
    { "filename": "bank-statement.pdf", "contentType": "application/pdf", "size": 184220, "url": "https://..." }
  ],
  "contactId": "cnt_77ab",
  "inReplyToEmailId": "em_4471receipt",
  "spamVerdict": "PASS",
  "receivedAt": "2026-09-14T09:12:04.000Z"
}

Journey events

journey.enrolled fires when a run is created.

json
{
  "journeyId": "jrn_abc123",
  "runId": "run_abc123",
  "contactId": "cnt_abc123"
}

journey.completed fires when a run finishes its last step.

json
{
  "journeyId": "jrn_abc123",
  "runId": "run_abc123",
  "contactId": "cnt_abc123",
  "completedAt": "2024-01-15T10:30:00.000Z"
}

journey.exited fires when a run leaves the journey before the end.

json
{
  "journeyId": "jrn_abc123",
  "runId": "run_abc123",
  "contactId": "cnt_abc123",
  "exitReason": "UNSUBSCRIBED"
}

Campaign events

campaign.started fires when the first batch of a campaign is queued.

json
{
  "campaignId": "cmp_abc123",
  "total": 4820
}

campaign.completed fires when the last email of a campaign is processed, which is the moment the campaign moves to SENT. Both counts are read from the campaign's emails at that moment: sent is every email the campaign handed off for delivery, and failed is every one that could not be handed off. Neither number reflects what the mailbox providers did afterwards. Use the email.delivered and email.bounced events for that.

json
{
  "campaignId": "cmp_abc123",
  "sent": 4796,
  "failed": 24
}

Deliverability events

deliverability.blocked fires when the pre-send check refuses a campaign send, a campaign schedule, a campaign resume, a journey activation, or an edit to a journey that is already past draft. kind is campaign, journeyStep or journeyStepDraft, and id names the campaign or the step. findingIds is empty when a blocking check did not finish in time.

json
{
  "kind": "campaign",
  "id": "cmp_abc123",
  "findingIds": ["unsubscribe_missing", "domain_unverified"]
}

Domain events

All domain events (domain.created, domain.verified, domain.updated, domain.deleted) include:

typescript
{
  id: number;              // Domain ID
  name: string;            // Domain name (e.g., "example.com")
  status: string;          // Domain status
  region: string;          // AWS region
  createdAt: string;       // ISO 8601 timestamp
  updatedAt: string;       // ISO 8601 timestamp
  clickTracking: boolean;  // Click tracking enabled
  openTracking: boolean;   // Open tracking enabled
  subdomain?: string;      // Subdomain for tracking
  dkimStatus?: string;     // DKIM verification status
  spfDetails?: string;     // SPF record details
  dmarcAdded?: boolean;    // DMARC record added
}