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.

Think of it as 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.

scripts/triage.ts
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

EventWhenPipeline continues?
payload.validatedPayload passes schema validationYes — always first
recipient.resolvedRecipient found in databaseYes — pipeline proceeds
idempotent.replaySend matched an existing idempotency keyNo — returns cached result
deduplicatedSend matched an existing dedup key within windowNo — send is dropped
rate_limitedSend exceeded rate limit thresholdNo — send is dropped

Phase 2: Resolution

EventWhenWhat it tells you
preferences.resolvedChannel preferences evaluatedWhich channels are allowed vs disabled
quiet_hours.deferredPush channels deferred to quiet hours endDelivery is scheduled, not lost
channel.skippedChannel skipped (preferences, missing address)Check metadata.reason for the specific cause
notification.suppressedAll channels skipped — nothing will deliverEvery channel was blocked — user won't see this

Phase 3: Delivery

EventWhenWhat it tells you
inbox.createdInbox item written to databasePull channel delivered — instant, no retries
delivery.createdPush delivery queued to providerJob is in the queue, waiting for execution
delivery.attemptProvider call attempted (may appear multiple times)Attempt number in metadata
delivery.sentProvider confirmed successful deliveryTerminal success — notification reached the provider
delivery.failedAll retries exhausted or permanent errorTerminal failure — check metadata.error

Phase 4: Provider details & fallback

EventWhenWhat it tells you
provider.message_id_storedProvider returned a message IDUse to cross-reference in provider dashboard
provider.errorProvider returned an error (before retry)Raw error — may retry after this
fallback.triggeredPrimary channel failed, fallback rule activatedWhich fallback rule matched and what channel fires next
Quick rule: if the last event in a timeline is in Phase 1, the notification was blocked early (dedup/rate limit). If it's in Phase 2, a resolution issue stopped it (preferences/quiet hours). If it's in Phase 3, delivery was attempted — check whether it ended with delivery.sent (success) or delivery.failed (provider issue).

Timeline event structure

Each event in the array has these fields:

FieldDescription
eventEvent type (see table above)
messageHuman-readable description of what happened
channelWhich channel this relates to (if applicable)
providerWhich provider was used (e.g. "resend", "twilio")
deliveryIdSet for delivery-specific events — use to filter
metadataArbitrary extra data (error messages, provider IDs, etc.)
timestampWhen this event occurred
seqOrdering 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:

1
Find the notification record

Look up by recipient + time window, or by the idempotency key your app passed to send().

2
Pull the timeline

Call notify.timeline(recordId). The last event in the list is where the pipeline stopped or completed.

3
Match the last event to a cause

Use the table below to translate the timeline into a root cause and next action.

Last event you seeRoot causeResolution
delivery.sentNotifyKit delivered successfully — issue is downstreamCheck spam folder, provider bounce logs, or recipient email validity
delivery.failedProvider rejected after retriesRead metadata.error — usually invalid address, rate limit, or provider outage
channel.skippedChannel didn't fire — check the reason fieldpreferences_disabled → user opted out. missing_address → no email on file
quiet_hours.deferredEmail is waiting for quiet hours to endNot lost — will deliver at the specified time. Verify flushScheduledSends() is running
deduplicatedDedup key matched within the windowIntentional? Check if the key is too broad. Not a bug if it's suppressing duplicates correctly
rate_limitedRecipient hit their rate limit for this notificationExpected protection. If the limit is too aggressive, increase max or widen the window
Only payload.validatedRecipient not found — upsertRecipient() wasn't calledEnsure the recipient exists before sending. Common in new user flows
No record found at allsend() was never called for this user/eventCheck your trigger logic — the issue is upstream of NotifyKit
90% of "didn't get it" tickets are one of three things: user opted out (check 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:

scripts/incident-triage.ts
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"}`)
}
Build an admin endpoint. Wrap this pattern in an API route behind admin auth. When a provider goes down, you can quickly see how many users were affected, which channels failed, and whether fallbacks caught them — without touching the database directly.

Measuring delivery latency

Timeline timestamps let you compute how long each stage takes. Use this to spot slow providers or unexpected queuing:

scripts/measure-latency.ts
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`)
ChannelLatency includesInvestigate against
InboxDatabase write and realtime publicationYour normal database p95 and product freshness SLO
Email (Resend)DNS, TLS, provider API, and persistenceYour provider's observed baseline and timeout
SMS (custom provider)Provider API acceptance, not carrier deliveryYour provider's observed baseline and rate limits
WebhookDNS, TLS, and the downstream handlerYour downstream service SLO and configured timeout
Alert on p95, not p50. A healthy median hides tail latency. Establish thresholds from real traffic and alert on sustained regression; a universal latency number would be misleading across different regions, providers, and database topologies.

Common timeline patterns

Match your timeline output against these patterns to quickly identify what happened:

You seeWhat happenedAction
channel.skipped with reason preferences_disabledUser opted out of this channelExpected behavior — no fix needed
delivery.attempt x3 then delivery.failedProvider is down or rejectingCheck provider dashboard, verify API key
quiet_hours.deferred with no later delivery.sentFlush hasn't run yetCheck your cron/interval for flushScheduledSends()
deduplicated immediately after payload.validatedSame dedup key sent within windowVerify dedup key design — may be too broad
fallback.triggered after delivery.failedPrimary channel failed, fallback kicked inWorking as intended — user still got notified
Only payload.validated then nothingRecipient not found in databaseCall 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 testOutcome-onlyTimeline assertion
Email deliveredPasses — but can't tell if it was allowed by preferences or forced by requiredAssert preferences.resolved shows email: allowed
Notification skippedPasses — but was it dedup, rate limit, or preferences?Assert the specific event (deduplicated vs rate_limited)
Fallback firedPasses — but did the primary actually fail or was it skipped?Assert delivery.failed precedes fallback.triggered

Pattern: assert pipeline path

tests/pipeline-path.test.ts
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:

tests/helpers/timeline.ts
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")
Timeline tests catch "works by accident" bugs. A notification that delivers because 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:

RetentionGood forApproximate rows (1k sends/day)
7 daysActive debugging only~50k rows
30 daysMost production apps~200k rows
90 daysCompliance/audit requirements~600k rows
scripts/prune-timeline.ts
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60_000)
const deleted = await notify.pruneTimeline(thirtyDaysAgo)
console.log(`Pruned ${deleted} timeline events`)
Set 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:

app/api/admin/timeline/route.ts
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 elementSource fieldRendering tip
Event icon/colorevent typeGreen for *.sent, red for *.failed, yellow for *.deferred
Timestamp deltatimestampShow "+120ms" relative to the first event, not absolute time
Provider badgeproviderShow which provider handled it (useful when you have fallbacks)
Error detailsmetadataExpand on click — contains raw error messages from providers
Channel filterchannelLet support filter to just email or just inbox events
Show relative timing, not absolute. A timeline that says "+0ms → +3ms → +45ms → +2100ms" instantly reveals where the latency is. Absolute timestamps (10:04:23.456) make you do math. Calculate: event.timestamp - events[0].timestamp.