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.

IntegrationHooks to use
Metrics (Datadog, Prometheus)notification.created, delivery.sent, delivery.failed
Error tracking (Sentry, Bugsnag)delivery.failed
Audit loginbox.deleted, notification.created, inbox.all_read
Alerting (PagerDuty, Slack)notification.rate_limited, notification.suppressed
Hooks are awaited by default. A slow hook blocks the send pipeline. Use 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…HookWhy this one
A notification was sent successfullydelivery.sentFires after the provider confirms — one per channel that delivered
A notification failed permanentlydelivery.failedFires after all retries exhausted — includes the final error
A user was skipped entirely (all channels blocked)notification.suppressedFires when preferences/quiet hours/etc. block every channel
A send hit the rate limit ceilingnotification.rate_limitedFires before any delivery attempt — the notification was dropped
A duplicate was caughtnotification.deduplicatedFires when the dedup key matches a recent send — no work done
An inbox item changed state (read, archived, deleted)inbox.updated / inbox.archived / inbox.deletedPer-action hooks for audit trails or analytics
How long delivery took (latency)delivery.sentCompare sentAt - createdAt in the delivery object

Configuring hooks

lib/notifykit.ts
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

HookFires whenContext
notification.createdRecord written to DBnotification, redactedPayload
notification.deduplicatedSkipped by dedup keynotificationId, dedupeKey, windowMs
notification.rate_limitedDropped by rate limitnotificationId, recipientId, limit
notification.suppressedAll channels skippednotificationId, skippedChannels

Delivery lifecycle

HookFires whenContext
delivery.sentProvider confirmed deliverydelivery, redactedPayload
delivery.failedAll retries exhausteddelivery, error, redactedPayload

Inbox lifecycle

HookFires whenContext
inbox.createdInbox item writteninboxItem
inbox.updatedMarked read/archived/unarchivedinboxItem
inbox.archivedItem archivedinboxItem
inbox.unarchivedItem unarchivedinboxItem
inbox.deletedItem permanently deleteditemId, recipientId
inbox.all_readmarkAllRead() calledrecipientId, 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:

1
Rate limit & dedup check

If blocked: notification.rate_limited or notification.deduplicated fires. Pipeline stops — no record written, no delivery attempted.

2
Record created

notification.created fires. The notification record is in the DB. Channels have not been evaluated yet.

3
Channel resolution

Preferences, quiet hours, and availability are checked per channel. If all channels are blocked: notification.suppressed fires.

4
Inbox write

inbox.created fires. The inbox item exists — user can fetch it immediately.

5
Push delivery (email, SMS, webhook)

Provider called, retries attempted. On success: delivery.sent. After all retries exhausted: delivery.failed.

HookPipeline stageWhat's already happenedWhat hasn't happened yet
notification.rate_limited1 (guard)Nothing — send rejected immediatelyNo record, no delivery, no inbox item
notification.deduplicated1 (guard)Nothing — duplicate key matchedNo record, no delivery, no inbox item
notification.created2 (record)Record written to DBChannel resolution, delivery, inbox
notification.suppressed3 (resolution)Record exists, all channels evaluatedNo delivery — every channel was blocked
inbox.created4 (inbox)Record exists, inbox item writtenPush channels may still be in-flight
delivery.sent5 (delivery)Record exists, provider confirmedOther channels may still be in-flight
delivery.failed5 (delivery)Record exists, all retries exhaustedFallback channel may trigger next
Inbox hooks fire independently of delivery hooks. A notification that delivers to both inbox and email will fire 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

GoalHookPattern
Count emails sent per minutedelivery.sentIncrement counter with channel: delivery.channel label
Alert on repeated failuresdelivery.failedFire-and-forget to PagerDuty/Slack with error details
Audit who unsubscribedinbox.deletedLog recipientId + timestamp to audit table
Detect suppressed notificationsnotification.suppressedLog when all channels skip — may indicate stale recipients
Track delivery latencydelivery.sentCompare delivery.sentAt - delivery.createdAt

Key metrics to track

Not sure what to measure? These five signals catch most production issues before users report them:

MetricHookAlert when
Delivery success ratedelivery.sent / delivery.failedSuccess rate drops below 95% over 5 minutes
Delivery latency (p95)delivery.sent — compare sentAt - createdAtp95 exceeds 30 seconds (provider slowdown or queue backup)
Rate limit hitsnotification.rate_limitedSustained spike — may indicate a bug sending in a loop
Suppression ratenotification.suppressedRising trend — may mean stale recipients or over-aggressive preferences
Dedup collision ratenotification.deduplicatedSudden spike — either a retry storm or a dedup key that's too broad
lib/notifykit.ts
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:

AlertLikely causeFirst response
Delivery success drops below your SLOProvider outage or API key revoked1. Check provider status page. 2. Verify API key in env. 3. If sustained, swap to backup provider.
Delivery p95 regresses from baselineQueue backup, provider slowdown, or event loop congestion1. 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 aggressively1. Check recent deploys. 2. Identify the source notification ID. 3. Add/tighten dedup key on the offending send.
Suppression rate rises unexpectedlyStale recipients, or a notification targeting users who've all opted out1. Check which notification ID dominates. 2. Audit recipient list freshness. 3. Consider removing inactive recipients.
Dedup collision spikeRetry storm from a queue or webhook, or dedup key that's too broad1. Check for duplicate job executions. 2. Verify dedup key includes enough specificity. 3. Check if a webhook source is retrying.
Correlate with deploys. Most alert spikes happen within minutes of a deployment. If your metrics dashboard can overlay deploy markers, the cause is usually obvious — a changed notification definition, a broken provider config, or a new send call in a hot loop.

Hook error handling

What happens when a hook itself throws? The behavior depends on whether you await the hook or fire-and-forget:

PatternIf hook throwsEffect on send()
Sync/awaited hookError propagates to send() callerSend still completes (delivery already happened), but the send() promise rejects
Fire-and-forget (void)Unhandled rejection — crashes process if uncaughtNone — 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:

lib/hooks/metrics.ts
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 })
  },
}
lib/hooks/errors.ts
export const errorHooks = {
  "delivery.failed": ({ delivery, error }) => {
    sentry.captureException(error, {
      tags: { channel: delivery.channel, provider: delivery.provider },
      extra: { deliveryId: delivery.id, notificationId: delivery.notificationId },
    })
  },
}
lib/hooks/audit.ts
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 })
  },
}
lib/notifykit.ts
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),
})
PatternWhen to useTrade-off
Single on objectSmall apps with 1-2 integrationsSimple but gets tangled quickly
Separate hook files + mergeProduction apps with metrics, errors, and auditClean separation — each file owns one concern
Conditional hooksDifferent behavior per environmentE.g. skip Sentry hooks in test, add verbose logging in dev
Hooks with the same event name stack, not replace. The 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:

lib/hooks/index.ts
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)
}
EnvironmentHooks activeWhy
TestNone (empty object)Tests inject their own hooks via vi.fn() — global hooks add noise and non-determinism
DevelopmentConsole logging onlyInstant feedback in the terminal without external deps. No metrics infrastructure needed locally.
StagingMetrics + errors (no paging)Validates hook wiring end-to-end. Sentry captures errors but alert rules are relaxed.
ProductionMetrics + errors + auditFull observability. Audit log for compliance. Alerts page oncall on sustained failures.
lib/notifykit.ts
import { buildHooks } from "./hooks"

export const notify = createNotifyKit({
  // ...
  on: buildHooks(),
})
Feature-flag individual integrations. If you want Sentry in dev but not metrics, check for the env var: 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:

lib/hooks/slack-alerts.ts
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 typeDebounce windowWhy
Delivery failures60 secondsProvider outages produce hundreds of failures per minute — batch into one summary
Rate limit hitsNone (immediate)Rare in normal operation — an immediate alert means a code bug is likely
Suppression spikes5 minutesGradual trend, not urgent — daily summary is often enough
lib/notifykit.ts
import { slackAlertHooks } from "./hooks/slack-alerts"

export const notify = createNotifyKit({
  // ...
  on: mergeHooks(metricsHooks, errorHooks, slackAlertHooks),
})
Slack webhooks have a 1 message/second rate limit. Without debouncing, a 100-failure burst triggers 100 webhook calls — Slack throttles after the first, and your alerts arrive minutes late or get dropped entirely. The buffer pattern above guarantees at most 1 message per DEBOUNCE_MS window regardless of failure volume.
Swap the transport for any chat system. Replace the 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:

tests/hooks.test.ts
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" }),
      })
    )
  })
})
Use memoryAdapter() and fakeEmailProvider() in tests. They run entirely in-process with no I/O, so hooks fire synchronously and assertions are deterministic.