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
| Event | Description |
|---|---|
email.queued | Email has been queued for sending |
email.sent | Email has been sent to the recipient's mail server |
email.delivered | Email was successfully delivered |
email.delivery_delayed | Email delivery is being retried |
email.bounced | Email bounced (permanent or temporary) |
email.rejected | Email was rejected |
email.rendering_failure | Email failed during template rendering |
email.complained | Recipient marked email as spam |
email.failed | Email failed to send |
email.cancelled | Scheduled email was cancelled |
email.suppressed | Email was suppressed (recipient on suppression list) |
email.opened | Recipient opened the email |
email.clicked | Recipient clicked a link in the email |
Contact events
| Event | Description |
|---|---|
contact.created | New contact was created |
contact.updated | Contact was updated |
contact.deleted | Contact was deleted |
contact.subscribed | Contact became subscribed |
contact.unsubscribed | Contact became unsubscribed |
Domain events
| Event | Description |
|---|---|
domain.created | New domain was added |
domain.verified | Domain verification completed |
domain.updated | Domain settings were updated |
domain.deleted | Domain was deleted |
Verification events
| Event | Description |
|---|---|
verification.sent | A verification code was queued for delivery |
verification.verified | A submitted code matched |
verification.failed | The code ran out of attempts or expired at check time |
verification.refused | Risk scoring refused the request |
verification.delivered | An 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
| Event | Description |
|---|---|
sms.sent | The provider accepted the message, status queued or sent |
sms.delivered | The provider confirmed delivery |
sms.failed | The message failed or was rejected, status failed or rejected |
Inbound email events
| Event | Description |
|---|---|
email.received | An inbox received a message that was not caught as spam |
Journey events
| Event | Description |
|---|---|
journey.enrolled | A contact was enrolled into a journey |
journey.completed | A run finished its last step |
journey.exited | A run left the journey before the end |
Campaign events
| Event | Description |
|---|---|
campaign.started | The first batch of a campaign is queued |
campaign.completed | The last email of a campaign is processed |
Deliverability events
| Event | Description |
|---|---|
deliverability.blocked | A 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.
{
"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
| Field | Description |
|---|---|
id | Unique identifier for this webhook call |
type | The event type (e.g., email.delivered) |
version | API version for the payload format |
createdAt | When the event was created |
teamId | Your team ID |
data | Event-specific data (varies by event type) |
attempt | Delivery attempt number (1-6) |
Request headers
Each webhook request includes the following headers:
| Header | Description |
|---|---|
X-Notix-Signature | HMAC-SHA256 signature for verification |
X-Notix-Timestamp | Unix timestamp in milliseconds |
X-Notix-Event | Event type |
X-Notix-Call | Unique webhook call ID |
X-Notix-Retry | true 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
npm install notix-js
# or: yarn add notix-js / pnpm add notix-js / bun add notix-js
Next.js App Router
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
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:
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:
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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 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:
- Open Webhooks in the console
- Open your webhook
- Send a test event
The test event will have type webhook.test with the following payload:
{
"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:
{
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:
{
// ... 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:
{
// ... base email fields
failed: {
reason: string; // Failure reason
}
}
email.suppressed
Includes suppression details:
{
// ... 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:
{
// ... 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:
{
// ... 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:
{
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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"journeyId": "jrn_abc123",
"runId": "run_abc123",
"contactId": "cnt_abc123"
}
journey.completed fires when a run finishes its last step.
{
"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.
{
"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.
{
"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.
{
"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.
{
"kind": "campaign",
"id": "cmp_abc123",
"findingIds": ["unsubscribe_missing", "domain_unverified"]
}
Domain events
All domain events (domain.created, domain.verified, domain.updated, domain.deleted) include:
{
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
}