Timeline
Every notification gets a debug timeline — a chronological log of every significant event from payload validation through final delivery. Use it to understand exactly what happened and why.
git log for a notification. When a user reports "I never got that email," pull the timeline and see exactly which step failed — was it preferences, quiet hours, a provider error, or something else entirely?Quick triage
Most "user didn't get it" tickets fall into three patterns. Look at the last event in the timeline:
channel.skipped
User opted out via preferences. Check metadata.reason — usually preferences_disabled or missing_address. Not a bug.
quiet_hours.deferred
Email is waiting — not lost. Will deliver at metadata.resumesAt. Verify flushScheduledSends() is running.
delivery.failed
Provider rejected after all retries. Read metadata.error — invalid address, rate limit, or provider outage.
const events = await notify.timeline(recordId)
const last = events[events.length - 1]
console.log(last.event, last.message, last.metadata)Enabling timeline
Timeline requires the timeline section in your database adapter. The Drizzle adapters include it by default. The memory adapter also supports it out of the box.
No additional configuration is needed — timeline events are automatically recorded during every send().
Reading the timeline
// Get timeline for a specific notification send
const events = await notify.timeline(notificationRecordId)
// Get timeline for a specific delivery
const deliveryEvents = await notify.timeline(notificationRecordId, { deliveryId })
// Example output:
// [
// { event: "payload.validated", message: "All 3 fields valid" },
// { event: "recipient.resolved", message: "Recipient user_123 found" },
// { event: "preferences.resolved", message: "inbox: allowed, email: allowed" },
// { event: "inbox.created", message: "Inbox item inb_abc created" },
// { event: "delivery.created", message: "Email delivery del_xyz queued" },
// { event: "delivery.attempt", message: "Attempt 1 via resend" },
// { event: "delivery.sent", message: "Sent via resend (msg_id: ...)" },
// ]Event types
Events are grouped by pipeline phase. When reading a timeline, events appear in this order — if the pipeline stops early (dedup, rate limit), later phases won't have entries.
Phase 1: Validation & guards
| Event | When | Pipeline continues? |
|---|---|---|
payload.validated | Payload passes schema validation | Yes — always first |
recipient.resolved | Recipient found in database | Yes — pipeline proceeds |
idempotent.replay | Send matched an existing idempotency key | No — returns cached result |
deduplicated | Send matched an existing dedup key within window | No — send is dropped |
rate_limited | Send exceeded rate limit threshold | No — send is dropped |
Phase 2: Resolution
| Event | When | What it tells you |
|---|---|---|
preferences.resolved | Channel preferences evaluated | Which channels are allowed vs disabled |
quiet_hours.deferred | Push channels deferred to quiet hours end | Delivery is scheduled, not lost |
channel.skipped | Channel skipped (preferences, missing address) | Check metadata.reason for the specific cause |
notification.suppressed | All channels skipped — nothing will deliver | Every channel was blocked — user won't see this |
Phase 3: Delivery
| Event | When | What it tells you |
|---|---|---|
inbox.created | Inbox item written to database | Pull channel delivered — instant, no retries |
delivery.created | Push delivery queued to provider | Job is in the queue, waiting for execution |
delivery.attempt | Provider call attempted (may appear multiple times) | Attempt number in metadata |
delivery.sent | Provider confirmed successful delivery | Terminal success — notification reached the provider |
delivery.failed | All retries exhausted or permanent error | Terminal failure — check metadata.error |
Phase 4: Provider details & fallback
| Event | When | What it tells you |
|---|---|---|
provider.message_id_stored | Provider returned a message ID | Use to cross-reference in provider dashboard |
provider.error | Provider returned an error (before retry) | Raw error — may retry after this |
fallback.triggered | Primary channel failed, fallback rule activated | Which fallback rule matched and what channel fires next |
delivery.sent (success) or delivery.failed (provider issue).Timeline event structure
Each event in the array has these fields:
| Field | Description |
|---|---|
event | Event type (see table above) |
message | Human-readable description of what happened |
channel | Which channel this relates to (if applicable) |
provider | Which provider was used (e.g. "resend", "twilio") |
deliveryId | Set for delivery-specific events — use to filter |
metadata | Arbitrary extra data (error messages, provider IDs, etc.) |
timestamp | When this event occurred |
seq | Ordering index within the same timestamp |
Debugging workflow
Most support tickets boil down to one question: "Why didn't the user get the notification?" Follow this triage flow:
Look up by recipient + time window, or by the idempotency key your app passed to send().
Call notify.timeline(recordId). The last event in the list is where the pipeline stopped or completed.
Use the table below to translate the timeline into a root cause and next action.
| Last event you see | Root cause | Resolution |
|---|---|---|
delivery.sent | NotifyKit delivered successfully — issue is downstream | Check spam folder, provider bounce logs, or recipient email validity |
delivery.failed | Provider rejected after retries | Read metadata.error — usually invalid address, rate limit, or provider outage |
channel.skipped | Channel didn't fire — check the reason field | preferences_disabled → user opted out. missing_address → no email on file |
quiet_hours.deferred | Email is waiting for quiet hours to end | Not lost — will deliver at the specified time. Verify flushScheduledSends() is running |
deduplicated | Dedup key matched within the window | Intentional? Check if the key is too broad. Not a bug if it's suppressing duplicates correctly |
rate_limited | Recipient hit their rate limit for this notification | Expected protection. If the limit is too aggressive, increase max or widen the window |
Only payload.validated | Recipient not found — upsertRecipient() wasn't called | Ensure the recipient exists before sending. Common in new user flows |
| No record found at all | send() was never called for this user/event | Check your trigger logic — the issue is upstream of NotifyKit |
channel.skipped), quiet hours deferred it (check quiet_hours.deferred), or provider rejected the address (check delivery.failed metadata). The timeline tells you which in seconds.Example: email deferred by quiet hours
A user reports they never received an email. Pull the timeline:
const events = await notify.timeline(notificationRecordId)
// → [
// { event: "payload.validated", message: "All fields valid" },
// { event: "recipient.resolved", message: "Recipient user_456 found" },
// { event: "preferences.resolved", message: "inbox: allowed, email: allowed" },
// { event: "quiet_hours.deferred", message: "Email deferred until 08:00 EST" },
// { event: "inbox.created", message: "Inbox item created" },
// ]The answer: the email wasn't dropped — it was deferred by quiet hours. Check quiet_hours.deferred for when it will send.
Querying for incidents
Timeline is per-notification, but during incidents you need a broader view. Combine deliveries.list() with timeline to investigate:
const oneHourAgo = new Date(Date.now() - 60 * 60_000)
const deliveries = await notify.deliveries.list()
const recentFailures = deliveries.filter(
d => d.status === "failed" && d.failedAt && d.failedAt > oneHourAgo
)
// Pull timeline for each to understand WHY they failed:
for (const d of recentFailures.slice(0, 10)) {
const events = await notify.timeline(d.notificationRecordId, { deliveryId: d.id })
const errorEvent = events.find(e => e.event === "provider.error")
console.log(`${d.channel} to ${d.recipientId}: ${errorEvent?.message ?? "unknown"}`)
}Measuring delivery latency
Timeline timestamps let you compute how long each stage takes. Use this to spot slow providers or unexpected queuing:
const deliveries = await notify.deliveries.list(undefined, undefined, 100)
const recordIds = [...new Set(deliveries.map(d => d.notificationRecordId))]
const latencies = await Promise.all(
recordIds.map(async (id) => {
const events = await notify.timeline(id)
const first = events[0]?.timestamp
const sent = events.find(e => e.event === "delivery.sent")?.timestamp
if (!first || !sent) return null
return { id, channel: events.find(e => e.channel)?.channel, ms: sent - first }
})
)
const valid = latencies.filter(Boolean)
const sorted = valid.sort((a, b) => a.ms - b.ms)
const p50 = sorted[Math.floor(sorted.length * 0.5)]?.ms
const p95 = sorted[Math.floor(sorted.length * 0.95)]?.ms
console.log(`p50: ${p50}ms, p95: ${p95}ms`)| Channel | Latency includes | Investigate against |
|---|---|---|
| Inbox | Database write and realtime publication | Your normal database p95 and product freshness SLO |
| Email (Resend) | DNS, TLS, provider API, and persistence | Your provider's observed baseline and timeout |
| SMS (custom provider) | Provider API acceptance, not carrier delivery | Your provider's observed baseline and rate limits |
| Webhook | DNS, TLS, and the downstream handler | Your downstream service SLO and configured timeout |
Common timeline patterns
Match your timeline output against these patterns to quickly identify what happened:
| You see | What happened | Action |
|---|---|---|
channel.skipped with reason preferences_disabled | User opted out of this channel | Expected behavior — no fix needed |
delivery.attempt x3 then delivery.failed | Provider is down or rejecting | Check provider dashboard, verify API key |
quiet_hours.deferred with no later delivery.sent | Flush hasn't run yet | Check your cron/interval for flushScheduledSends() |
deduplicated immediately after payload.validated | Same dedup key sent within window | Verify dedup key design — may be too broad |
fallback.triggered after delivery.failed | Primary channel failed, fallback kicked in | Working as intended — user still got notified |
Only payload.validated then nothing | Recipient not found in database | Call upsertRecipient() before send() |
Testing with timeline assertions
Most notification tests assert on the outcome ("delivered" or "skipped"). Timeline lets you assert on the path — verifying why the engine made a decision, not just what it did. This catches subtle bugs: a notification that delivers for the wrong reason (e.g., required: true overriding a preference you thought was active).
| What you test | Outcome-only | Timeline assertion |
|---|---|---|
| Email delivered | Passes — but can't tell if it was allowed by preferences or forced by required | Assert preferences.resolved shows email: allowed |
| Notification skipped | Passes — but was it dedup, rate limit, or preferences? | Assert the specific event (deduplicated vs rate_limited) |
| Fallback fired | Passes — but did the primary actually fail or was it skipped? | Assert delivery.failed precedes fallback.triggered |
Pattern: assert pipeline path
import { describe, it, expect } from "vitest"
import { notify } from "./test-setup"
describe("pipeline path verification", () => {
it("delivers email through preferences (not forced)", async () => {
const result = await notify.send({
recipientId: "user_1",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
})
const events = await notify.timeline(result.id)
// Verify preferences were consulted (not bypassed by required)
const prefEvent = events.find(e => e.event === "preferences.resolved")
expect(prefEvent).toBeDefined()
expect(prefEvent!.message).toContain("email: allowed")
// Verify no fallback was needed
expect(events.find(e => e.event === "fallback.triggered")).toBeUndefined()
// Verify delivery succeeded on first attempt
const attempts = events.filter(e => e.event === "delivery.attempt")
expect(attempts).toHaveLength(1)
})
it("skips for the RIGHT reason (dedup, not rate limit)", async () => {
// First send
await notify.send({
recipientId: "user_1",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
dedupeKey: "mention:p1:rey",
dedupeWindowMs: 60_000,
})
// Second send — should be dedup, not rate limit
const second = await notify.send({
recipientId: "user_1",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
dedupeKey: "mention:p1:rey",
dedupeWindowMs: 60_000,
})
const events = await notify.timeline(second.id)
expect(events.find(e => e.event === "deduplicated")).toBeDefined()
expect(events.find(e => e.event === "rate_limited")).toBeUndefined()
})
it("fallback fires after primary failure (not preference skip)", async () => {
// Configure provider to fail for this test
const result = await notify.send({
recipientId: "user_1",
notificationId: "urgent_alert",
payload: { message: "Server down" },
})
const events = await notify.timeline(result.id)
const eventTypes = events.map(e => e.event)
// Verify the sequence: attempt → fail → fallback
const failIdx = eventTypes.indexOf("delivery.failed")
const fallbackIdx = eventTypes.indexOf("fallback.triggered")
expect(failIdx).toBeGreaterThan(-1)
expect(fallbackIdx).toBeGreaterThan(failIdx)
})
})Helper: timeline matchers
For larger test suites, extract reusable matchers that keep tests readable:
export function expectEventSequence(events, expectedTypes: string[]) {
const types = events.map(e => e.event)
let cursor = 0
for (const expected of expectedTypes) {
const idx = types.indexOf(expected, cursor)
if (idx === -1) {
throw new Error(
`Expected event "${expected}" after position ${cursor}, \n` +
`but timeline only contains: [${types.slice(cursor).join(", ")}]`
)
}
cursor = idx + 1
}
}
export function expectNoEvent(events, eventType: string) {
const found = events.find(e => e.event === eventType)
if (found) {
throw new Error(
`Expected no "${eventType}" event, but found one: ${found.message}`
)
}
}
// Usage:
const events = await notify.timeline(result.id)
expectEventSequence(events, [
"payload.validated",
"preferences.resolved",
"delivery.sent",
])
expectNoEvent(events, "fallback.triggered")required: true passes outcome tests — but if you meant it to respect preferences, a timeline assertion catches the misconfiguration immediately. Test the path, not just the destination.Pruning old events
Timeline events accumulate over time. Prune them periodically to keep your database lean:
| Retention | Good for | Approximate rows (1k sends/day) |
|---|---|---|
| 7 days | Active debugging only | ~50k rows |
| 30 days | Most production apps | ~200k rows |
| 90 days | Compliance/audit requirements | ~600k rows |
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60_000)
const deleted = await notify.pruneTimeline(thirtyDaysAgo)
console.log(`Pruned ${deleted} timeline events`)timelineRetentionMs for auto-prune. When configured, calling pruneTimeline() with no arguments uses the configured retention window — no date math needed.Building an admin timeline viewer
Support teams need to look up timelines by user or notification without touching the database. Build a simple API endpoint and render results in your admin panel:
import { notify } from "@/lib/notifykit"
import { requireAdmin } from "@/lib/auth"
export async function GET(request: Request) {
await requireAdmin(request)
const url = new URL(request.url)
const recordId = url.searchParams.get("id")!
const events = await notify.timeline(recordId)
// Group by phase for a clean display
const phases = {
validation: events.filter(e => e.event.startsWith("payload") || e.event.startsWith("recipient")),
resolution: events.filter(e => e.event.includes("preferences") || e.event.includes("quiet_hours")),
delivery: events.filter(e => e.event.startsWith("delivery") || e.event.startsWith("inbox")),
fallback: events.filter(e => e.event.startsWith("fallback") || e.event === "channel.skipped"),
}
return Response.json({ events, phases, total: events.length })
}| Display element | Source field | Rendering tip |
|---|---|---|
| Event icon/color | event type | Green for *.sent, red for *.failed, yellow for *.deferred |
| Timestamp delta | timestamp | Show "+120ms" relative to the first event, not absolute time |
| Provider badge | provider | Show which provider handled it (useful when you have fallbacks) |
| Error details | metadata | Expand on click — contains raw error messages from providers |
| Channel filter | channel | Let support filter to just email or just inbox events |
event.timestamp - events[0].timestamp.