Hooks & observability
Every significant moment in the notification pipeline fires a hook. Use them to pipe data into your metrics, audit log, error tracker, or any external system.
Metrics & dashboards
Track delivery rates, latency percentiles, and channel-level success in Datadog, Prometheus, or any metrics backend.
Error tracking
Pipe delivery failures into Sentry or Bugsnag with full context — channel, provider, error, attempt count.
Audit logging
Record who deleted inbox items, when notifications were sent, and preference changes for compliance.
Alerting
Fire Slack or PagerDuty alerts on rate limit spikes, sustained failures, or rising suppression rates.
| Integration | Hooks to use |
|---|---|
| Metrics (Datadog, Prometheus) | notification.created, delivery.sent, delivery.failed |
| Error tracking (Sentry, Bugsnag) | delivery.failed |
| Audit log | inbox.deleted, notification.created, inbox.all_read |
| Alerting (PagerDuty, Slack) | notification.rate_limited, notification.suppressed |
void to fire-and-forget if your external call is slow — see the Async safety section below.Which hook do I need?
Start from what you want to know, not the lifecycle category:
| I want to know… | Hook | Why this one |
|---|---|---|
| A notification was sent successfully | delivery.sent | Fires after the provider confirms — one per channel that delivered |
| A notification failed permanently | delivery.failed | Fires after all retries exhausted — includes the final error |
| A user was skipped entirely (all channels blocked) | notification.suppressed | Fires when preferences/quiet hours/etc. block every channel |
| A send hit the rate limit ceiling | notification.rate_limited | Fires before any delivery attempt — the notification was dropped |
| A duplicate was caught | notification.deduplicated | Fires when the dedup key matches a recent send — no work done |
| An inbox item changed state (read, archived, deleted) | inbox.updated / inbox.archived / inbox.deleted | Per-action hooks for audit trails or analytics |
| How long delivery took (latency) | delivery.sent | Compare sentAt - createdAt in the delivery object |
Configuring hooks
const notify = createNotifyKit({
// ...
on: {
"notification.created": ({ notification }) => {
metrics.inc("notifications.created", {
id: notification.notificationId,
})
},
"delivery.sent": ({ delivery }) => {
metrics.inc("delivery.sent", {
channel: delivery.channel,
provider: delivery.provider,
})
},
"delivery.failed": ({ delivery, error }) => {
sentry.captureException(error, {
extra: { deliveryId: delivery.id, channel: delivery.channel },
})
},
"notification.rate_limited": ({ notificationId, recipientId, limit }) => {
logger.warn("rate limited", { notificationId, recipientId, limit })
},
},
})This covers the most common pattern: count sends, track failures, alert on rate limits. See the full list below for every available hook.
Available hooks
Notification lifecycle
| Hook | Fires when | Context |
|---|---|---|
notification.created | Record written to DB | notification, redactedPayload |
notification.deduplicated | Skipped by dedup key | notificationId, dedupeKey, windowMs |
notification.rate_limited | Dropped by rate limit | notificationId, recipientId, limit |
notification.suppressed | All channels skipped | notificationId, skippedChannels |
Delivery lifecycle
| Hook | Fires when | Context |
|---|---|---|
delivery.sent | Provider confirmed delivery | delivery, redactedPayload |
delivery.failed | All retries exhausted | delivery, error, redactedPayload |
Inbox lifecycle
| Hook | Fires when | Context |
|---|---|---|
inbox.created | Inbox item written | inboxItem |
inbox.updated | Marked read/archived/unarchived | inboxItem |
inbox.archived | Item archived | inboxItem |
inbox.unarchived | Item unarchived | inboxItem |
inbox.deleted | Item permanently deleted | itemId, recipientId |
inbox.all_read | markAllRead() called | recipientId, count |
Hook timing in the pipeline
Hooks fire at specific points in the send pipeline. Understanding when each hook fires tells you what has already happened (and what hasn't) when your code runs:
If blocked: notification.rate_limited or notification.deduplicated fires. Pipeline stops — no record written, no delivery attempted.
notification.created fires. The notification record is in the DB. Channels have not been evaluated yet.
Preferences, quiet hours, and availability are checked per channel. If all channels are blocked: notification.suppressed fires.
inbox.created fires. The inbox item exists — user can fetch it immediately.
Provider called, retries attempted. On success: delivery.sent. After all retries exhausted: delivery.failed.
| Hook | Pipeline stage | What's already happened | What hasn't happened yet |
|---|---|---|---|
notification.rate_limited | 1 (guard) | Nothing — send rejected immediately | No record, no delivery, no inbox item |
notification.deduplicated | 1 (guard) | Nothing — duplicate key matched | No record, no delivery, no inbox item |
notification.created | 2 (record) | Record written to DB | Channel resolution, delivery, inbox |
notification.suppressed | 3 (resolution) | Record exists, all channels evaluated | No delivery — every channel was blocked |
inbox.created | 4 (inbox) | Record exists, inbox item written | Push channels may still be in-flight |
delivery.sent | 5 (delivery) | Record exists, provider confirmed | Other channels may still be in-flight |
delivery.failed | 5 (delivery) | Record exists, all retries exhausted | Fallback channel may trigger next |
inbox.created (stage 4) before delivery.sent (stage 5). Don't assume the email has been sent when your inbox hook runs.Async safety
Hooks can be async. The engine awaits them — a slow hook blocks the pipeline. For fire-and-forget behavior, don't return the promise:
on: {
"delivery.sent": ({ delivery }) => {
// Fire and forget — don't await, don't block send()
void analyticsClient.track("email_sent", { deliveryId: delivery.id })
},
}Common recipes
| Goal | Hook | Pattern |
|---|---|---|
| Count emails sent per minute | delivery.sent | Increment counter with channel: delivery.channel label |
| Alert on repeated failures | delivery.failed | Fire-and-forget to PagerDuty/Slack with error details |
| Audit who unsubscribed | inbox.deleted | Log recipientId + timestamp to audit table |
| Detect suppressed notifications | notification.suppressed | Log when all channels skip — may indicate stale recipients |
| Track delivery latency | delivery.sent | Compare delivery.sentAt - delivery.createdAt |
Key metrics to track
Not sure what to measure? These five signals catch most production issues before users report them:
| Metric | Hook | Alert when |
|---|---|---|
| Delivery success rate | delivery.sent / delivery.failed | Success rate drops below 95% over 5 minutes |
| Delivery latency (p95) | delivery.sent — compare sentAt - createdAt | p95 exceeds 30 seconds (provider slowdown or queue backup) |
| Rate limit hits | notification.rate_limited | Sustained spike — may indicate a bug sending in a loop |
| Suppression rate | notification.suppressed | Rising trend — may mean stale recipients or over-aggressive preferences |
| Dedup collision rate | notification.deduplicated | Sudden spike — either a retry storm or a dedup key that's too broad |
on: {
"delivery.sent": ({ delivery }) => {
metrics.inc("notifykit.delivery.sent", { channel: delivery.channel })
metrics.histogram("notifykit.delivery.latency_ms",
delivery.sentAt - delivery.createdAt, { channel: delivery.channel })
},
"delivery.failed": ({ delivery, error }) => {
metrics.inc("notifykit.delivery.failed", { channel: delivery.channel })
sentry.captureException(error, { extra: { deliveryId: delivery.id } })
},
"notification.rate_limited": ({ notificationId }) => {
metrics.inc("notifykit.rate_limited", { notification: notificationId })
},
"notification.suppressed": ({ notificationId }) => {
metrics.inc("notifykit.suppressed", { notification: notificationId })
},
}When alerts fire — incident playbook
Metrics without a response plan are noise. This table maps each alert to what's likely broken and the first three steps to take:
| Alert | Likely cause | First response |
|---|---|---|
| Delivery success drops below your SLO | Provider outage or API key revoked | 1. Check provider status page. 2. Verify API key in env. 3. If sustained, swap to backup provider. |
| Delivery p95 regresses from baseline | Queue backup, provider slowdown, or event loop congestion | 1. Check queue depth. 2. Check provider response times. 3. Scale workers or increase concurrency. |
| Rate limit spike (10x normal) | Bug sending in a loop, or an automated process retrying aggressively | 1. Check recent deploys. 2. Identify the source notification ID. 3. Add/tighten dedup key on the offending send. |
| Suppression rate rises unexpectedly | Stale recipients, or a notification targeting users who've all opted out | 1. Check which notification ID dominates. 2. Audit recipient list freshness. 3. Consider removing inactive recipients. |
| Dedup collision spike | Retry storm from a queue or webhook, or dedup key that's too broad | 1. Check for duplicate job executions. 2. Verify dedup key includes enough specificity. 3. Check if a webhook source is retrying. |
Hook error handling
What happens when a hook itself throws? The behavior depends on whether you await the hook or fire-and-forget:
| Pattern | If hook throws | Effect on send() |
|---|---|---|
| Sync/awaited hook | Error propagates to send() caller | Send still completes (delivery already happened), but the send() promise rejects |
Fire-and-forget (void) | Unhandled rejection — crashes process if uncaught | None — send already returned successfully |
// SAFE: wrap external calls in try/catch
on: {
"delivery.sent": ({ delivery }) => {
try {
metrics.inc("notifykit.sent", { channel: delivery.channel })
} catch (err) {
// Don't let a metrics failure break sends
console.error("Hook error (metrics):", err)
}
},
"delivery.failed": ({ delivery, error }) => {
// SAFE fire-and-forget: .catch() prevents unhandled rejection
sentryClient.captureException(error, {
extra: { deliveryId: delivery.id },
}).catch(hookErr => console.error("Hook error (sentry):", hookErr))
},
}Hooks should never break sends
Wrap every hook in try/catch or add .catch() to fire-and-forget promises. A notification that delivers but fails to log is better than one that fails entirely.
Keep hooks off the critical path
Awaited hooks add directly to send() latency. If your integration needs network calls, use fire-and-forget or batch into a local buffer that flushes on an interval.
Never call send() inside a hook
This creates infinite recursion. If you need to trigger a follow-up notification, enqueue it via your job system — don't call notify.send() directly.
Payload redaction
Hooks that expose payload data receive redactedPayload — a copy with sensitive fields (declared in the notification's redact array) replaced by "[REDACTED]". This makes it safe to pipe hook data directly into external systems without leaking PII.
Composing multiple handlers
As your observability grows, cramming everything into one on object gets messy. Split concerns into standalone hook sets and merge them:
export const metricsHooks = {
"delivery.sent": ({ delivery }) => {
metrics.inc("notifykit.sent", { channel: delivery.channel })
metrics.histogram("notifykit.latency_ms",
delivery.sentAt - delivery.createdAt, { channel: delivery.channel })
},
"delivery.failed": ({ delivery }) => {
metrics.inc("notifykit.failed", { channel: delivery.channel })
},
}export const errorHooks = {
"delivery.failed": ({ delivery, error }) => {
sentry.captureException(error, {
tags: { channel: delivery.channel, provider: delivery.provider },
extra: { deliveryId: delivery.id, notificationId: delivery.notificationId },
})
},
}export const auditHooks = {
"notification.created": ({ notification }) => {
void auditLog.write("notification.sent", {
recipientId: notification.recipientId,
notificationId: notification.notificationId,
})
},
"inbox.deleted": ({ itemId, recipientId }) => {
void auditLog.write("inbox.deleted", { itemId, recipientId })
},
}import { metricsHooks } from "./hooks/metrics"
import { errorHooks } from "./hooks/errors"
import { auditHooks } from "./hooks/audit"
function mergeHooks(...hookSets) {
const merged = {}
for (const set of hookSets) {
for (const [event, handler] of Object.entries(set)) {
const prev = merged[event]
merged[event] = prev
? (ctx) => { prev(ctx); handler(ctx) }
: handler
}
}
return merged
}
export const notify = createNotifyKit({
// ...
on: mergeHooks(metricsHooks, errorHooks, auditHooks),
})| Pattern | When to use | Trade-off |
|---|---|---|
Single on object | Small apps with 1-2 integrations | Simple but gets tangled quickly |
| Separate hook files + merge | Production apps with metrics, errors, and audit | Clean separation — each file owns one concern |
| Conditional hooks | Different behavior per environment | E.g. skip Sentry hooks in test, add verbose logging in dev |
mergeHooks() helper runs all handlers for the same event in order. If one throws, later handlers still run — wrap each in try/catch if isolation matters.Conditional hooks by environment
Different environments have different observability needs. Dev wants verbose console output, staging wants Sentry but not paging, and production wants full metrics + alerting. Build this with conditional composition:
import { metricsHooks } from "./metrics"
import { errorHooks } from "./errors"
import { auditHooks } from "./audit"
const devHooks = {
"notification.created": ({ notification }) => {
console.log("[notifykit]", notification.notificationId, "→", notification.recipientId)
},
"delivery.sent": ({ delivery }) => {
console.log("[notifykit] ✓", delivery.channel, "sent to", delivery.recipientId)
},
"delivery.failed": ({ delivery, error }) => {
console.error("[notifykit] ✗", delivery.channel, "failed:", error.message)
},
}
export function buildHooks() {
const env = process.env.NODE_ENV
if (env === "test") return {} // no hooks in test — use vi.fn() explicitly
if (env === "development") return mergeHooks(devHooks)
// production + staging:
return mergeHooks(metricsHooks, errorHooks, auditHooks)
}| Environment | Hooks active | Why |
|---|---|---|
| Test | None (empty object) | Tests inject their own hooks via vi.fn() — global hooks add noise and non-determinism |
| Development | Console logging only | Instant feedback in the terminal without external deps. No metrics infrastructure needed locally. |
| Staging | Metrics + errors (no paging) | Validates hook wiring end-to-end. Sentry captures errors but alert rules are relaxed. |
| Production | Metrics + errors + audit | Full observability. Audit log for compliance. Alerts page oncall on sustained failures. |
import { buildHooks } from "./hooks"
export const notify = createNotifyKit({
// ...
on: buildHooks(),
})process.env.SENTRY_DSN ? errorHooks : {}. This lets developers opt into specific integrations locally without changing shared config.Recipe: Slack alerting with debounce
The most common hook integration is alerting a Slack channel when deliveries fail. But during a provider outage, a naive implementation floods the channel with hundreds of messages. Use a debounce buffer to batch failures into periodic summaries:
const SLACK_WEBHOOK = process.env.SLACK_ALERTS_WEBHOOK!
const DEBOUNCE_MS = 60_000 // batch failures over 1 minute
let buffer: Array<{ channel: string; error: string; notificationId: string }> = []
let flushTimer: ReturnType<typeof setTimeout> | null = null
function scheduleFlush() {
if (flushTimer) return // already scheduled
flushTimer = setTimeout(async () => {
const batch = buffer.splice(0) // drain buffer
flushTimer = null
if (batch.length === 0) return
const summary = batch.length === 1
? `Delivery failed: ${batch[0].notificationId} → ${batch[0].channel} (${batch[0].error})`
: `${batch.length} deliveries failed in the last minute:\n` +
Object.entries(Object.groupBy(batch, f => f.channel))
.map(([ch, items]) => `• ${ch}: ${items!.length} failures`)
.join("\n")
await fetch(SLACK_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `🚨 *NotifyKit alert*\n${summary}`,
}),
}).catch(err => console.error("Slack alert failed:", err))
}, DEBOUNCE_MS)
}
export const slackAlertHooks = {
"delivery.failed": ({ delivery, error }) => {
buffer.push({
channel: delivery.channel,
error: error?.message ?? "unknown",
notificationId: delivery.notificationId,
})
scheduleFlush()
},
"notification.rate_limited": ({ notificationId, recipientId }) => {
// Rate limits are less noisy — alert immediately but only once per notification type
void fetch(SLACK_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `⚠️ Rate limit hit: ${notificationId} for ${recipientId}`,
}),
}).catch(() => {})
},
}| Alert type | Debounce window | Why |
|---|---|---|
| Delivery failures | 60 seconds | Provider outages produce hundreds of failures per minute — batch into one summary |
| Rate limit hits | None (immediate) | Rare in normal operation — an immediate alert means a code bug is likely |
| Suppression spikes | 5 minutes | Gradual trend, not urgent — daily summary is often enough |
import { slackAlertHooks } from "./hooks/slack-alerts"
export const notify = createNotifyKit({
// ...
on: mergeHooks(metricsHooks, errorHooks, slackAlertHooks),
})DEBOUNCE_MS window regardless of failure volume.fetch(SLACK_WEBHOOK, ...) call with your preferred alerting destination — Discord webhooks, Microsoft Teams connectors, PagerDuty events API, or a custom internal alerting service. The debounce logic stays the same.Testing hooks
Verify your hooks fire correctly without hitting real external services. Use a spy to capture hook calls:
import { describe, it, expect, vi } from "vitest"
import { createNotifyKit, memoryAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { commentMentioned } from "./notifications"
describe("hooks", () => {
it("fires delivery.sent on successful email", async () => {
const onSent = vi.fn()
const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: fakeEmailProvider() },
on: { "delivery.sent": onSent },
})
await notify.upsertRecipient({ id: "u1", email: "test@test.com" })
await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
})
expect(onSent).toHaveBeenCalledTimes(1)
expect(onSent).toHaveBeenCalledWith(
expect.objectContaining({
delivery: expect.objectContaining({ channel: "email", status: "sent" }),
})
)
})
it("fires delivery.failed after retries exhaust", async () => {
const onFailed = vi.fn()
const failingProvider = { id: "broken", send: () => { throw new Error("down") } }
const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: failingProvider },
retry: { maxAttempts: 2, delayMs: () => 0 },
on: { "delivery.failed": onFailed },
})
await notify.upsertRecipient({ id: "u1", email: "test@test.com" })
await notify.send({
recipientId: "u1",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
})
expect(onFailed).toHaveBeenCalledTimes(1)
expect(onFailed).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.any(Error),
delivery: expect.objectContaining({ channel: "email" }),
})
)
})
})memoryAdapter() and fakeEmailProvider() in tests. They run entirely in-process with no I/O, so hooks fire synchronously and assertions are deterministic.