Hooks and observability
Feed lifecycle events into metrics, audit logs, error tracking, and alerts.
Configuring
Pass handlers in on. Each gets one context object, and can be async.
export const notify = createNotifyKit({
// ...
on: {
"delivery.sent": ({ delivery }) => {
metrics.increment("notifykit.sent", {
channel: delivery.channel,
provider: delivery.provider,
})
},
"delivery.failed": ({ delivery, error }) => {
Sentry.captureException(error, { extra: { deliveryId: delivery.id } })
},
"notification.rate_limited": ({ notificationId, recipientId, limit }) => {
logger.warn("rate limited", {
notificationId,
recipientId,
max: limit.max,
})
},
},
onHookError: (hook, error) => {
logger.error(`notifykit hook ${hook} failed`, error) // keep sends going
},
})Available Hooks
Sends
| Hook | Runs when | Context |
|---|---|---|
notification.created | A notification record is written, including for sends that are then deduplicated, suppressed, or rate-limited | notification, redactedPayload |
notification.deduplicated | The dedupe key was seen inside its window | notificationId, recipientId, dedupeKey, windowMs |
notification.suppressed | Every channel is off or has no address, and no fallback applies | notificationId, recipientId, skippedChannels, skipped |
notification.rate_limited | The send is over its rate limit | notificationId, recipientId, limit |
Deliveries
| Hook | Runs when | Context |
|---|---|---|
delivery.sent | A provider accepted an email, SMS, or webhook | delivery, redactedPayload |
delivery.failed | No attempts are left, or the error was permanent; before any fallback | delivery, error, redactedPayload |
There is no hook per retry. Individual attempts and errors are in the timeline.
Inbox
| Hook | Runs when | Context |
|---|---|---|
inbox.created | An inbox item is written, including by a fallback | inboxItem |
inbox.updated | An item is marked read | inboxItem |
inbox.archived, inbox.unarchived | An item is archived or restored | inboxItem |
inbox.deleted | An item is deleted | itemId, recipientId |
inbox.all_read | Everything is marked read | recipientId, count |
Order in a Send
notification.createdruns as soon as the record exists, before any channel is handled. A send stopped by deduplication, preferences, or a rate limit still creates a record, so this hook runs first and the matchingdeduplicated,suppressed, orrate_limitedhook runs after it.inbox.createdruns when the inbox item is written, which happens before email and other channels are sent.delivery.sentordelivery.failedruns per delivery. With the defaultinlineQueue()that happens insidesend(); with any other queue, later.
A send that joins a digest runs no hooks until the digest flushes. Channels held by quiet hours run their delivery hooks when they are finally sent.
When a Hook Throws
Hooks are awaited, and by default an error in one is re-thrown. That stops the rest of the send: a throwing inbox.created hook means the email is never queued, and send() rejects even though a record was stored.
Hook time also adds to send() time. For slow calls, start the work without awaiting it, and catch its errors yourself so they do not become unhandled rejections:
"delivery.sent": ({ delivery }) => {
void analytics
.track("notification_delivered", { channel: delivery.channel })
.catch((err) => logger.warn("analytics failed", err))
},Do not call notify.send() from a hook. A follow-up notification belongs in your job queue.
Redaction
redactedPayload is the payload with every field in the definition’s redact list replaced by "[REDACTED]", so it is safe to send to logs and third parties. notification.payload is the full payload; do not forward it.
notification({
id: "password_changed",
payload: { email: "string", ip: "string" },
channels: [inbox({ title: "Your password was changed" })],
redact: ["email", "ip"],
})What to Measure
| Signal | From | Worth a look when |
|---|---|---|
| Delivery success rate | delivery.sent and delivery.failed, by provider | It drops below your usual rate: an outage, an expired key, a lost domain verification |
| Delivery latency | delivery.sentAt minus delivery.createdAt | p95 rises: a slow provider or a backed-up queue |
| Rate-limit hits | notification.rate_limited | They spike: often a bug sending in a loop |
| Suppressions | notification.suppressed | They climb: stale recipients or missing addresses |
| Duplicates | notification.deduplicated | They spike: a retry storm, or a key that is too broad |
"delivery.sent": ({ delivery }) => {
const ms = delivery.sentAt!.getTime() - delivery.createdAt.getTime()
metrics.histogram("notifykit.latency_ms", ms, { channel: delivery.channel })
},Most spikes follow a deploy, so plot deploy markers next to these. For a single failure, the timeline has the details.
Composing
on takes one function per hook. To keep metrics, error tracking, and audit logging in separate files, merge them yourself:
import type { Hooks } from "@notifykitjs/core"
export function mergeHooks(...sets: Hooks[]): Hooks {
const merged: Record<string, (ctx: never) => Promise<void>> = {}
for (const set of sets) {
for (const [name, fn] of Object.entries(set)) {
if (!fn) continue
const prev = merged[name]
merged[name] = async (ctx) => {
await prev?.(ctx)
await (fn as (ctx: never) => unknown)(ctx)
}
}
}
return merged as Hooks
}
// createNotifyKit({ on: mergeHooks(metricsHooks, errorHooks, auditHooks) })With this version, a handler that throws stops the ones after it for that event; catch inside each handler if they must all run.