Defining notifications

A notification has an id, a payload schema, and a list of channels. Define them in code. Your notification IDs and payload shapes become typed values the rest of your app can rely on.

Identifier

A unique string like "comment_mentioned". Used in send(), preferences, and rate limit scoping.

Payload

A typed schema of the data this notification needs. Enforced at compile time and validated at runtime.

Channels

Where to deliver — inbox, email, SMS, webhook. Each channel has its own template using {{payload}} interpolation.

Basic definition

import { channel, notification } from "@notifykitjs/core"

const inbox = channel.inbox()
const email = channel.email()

export const commentMentioned = notification({
  id: "comment_mentioned",
  payload: {
    actorName: "string",
    postTitle: "string",
    postUrl: "string",
  },
  channels: [
    inbox({
      title: "{{actorName}} mentioned you",
      body: "In {{postTitle}}",
      actionUrl: "{{postUrl}}",
    }),
    email({
      subject: "{{actorName}} mentioned you in {{postTitle}}",
      body: "Open {{postUrl}} to reply.",
    }),
  ],
})

Payload schema

ApproachWhen to useProvides
Inline schemaFlat payloads with simple typesCompile-time types + runtime type check
Zod / Valibot / ArkTypeNested objects, arrays, refinements (UUID, positive, min/max)Full schema validation + compile-time types

Inline schema

A Record<string, "string" | "number" | "boolean"> — simple, zero dependencies:

notification({
  id: "order_shipped",
  payload: {
    orderNumber: "string",
    total:       "number",
    expedited:   "boolean",
  },
  channels: [...],
})

Zod validation

For complex shapes, use zodPayload() to get both the schema definition and a runtime validator:

import { z } from "zod"
import { zodPayload } from "@notifykitjs/core/zod"

const invoicePayload = zodPayload(z.object({
  invoiceId: z.string().uuid(),
  amount: z.number().positive(),
}))

notification({
  id: "invoice_created",
  payload: invoicePayload.payload,
  validate: invoicePayload.validate,
  channels: [...],
})
Validation runs on every send(). If the payload doesn't match, the send short-circuits with invalid_payload — no delivery attempts, no records written. Check result.skipped or use explain() to debug.

Templates

Every channel's string fields support {{key}} interpolation against the payload:

TemplatePayloadResult
{{actorName}} mentioned you{ actorName: "Rey" }Rey mentioned you
In {{postTitle}}{ postTitle: "Launch" }In Launch
{{missing}}{}(empty string)
Reserved variable. Email body gets {{_unsubscribeUrl}} injected automatically when unsubscribe is configured. See Preferences & unsubscribe.

Optional fields

Beyond the three required fields, notifications accept configuration for metadata, behavior, and delivery control:

FieldPurposeWhen to use
descriptionHuman label for admin UIsAlways — helps non-engineers understand the notification
categoryGroups notifications in preference UIsWhen you have 5+ notifications that need grouping
requiredBypasses preference checksTransactional: password resets, 2FA, billing receipts
defaultChannelsOverride which channels are on by defaultWhen a notification shouldn't email by default
redactMask fields in logs and hooksPayload contains PII (emails, IPs, names)
rateLimitHard-cap sends per windowPreventing notification spam
digestBatch sends into one deliveryHigh-frequency events (comments, likes)
fallbackCatch failed or skipped channelsCritical notifications that must reach the user
notification({
  id: "team_invite",
  payload: { inviterName: "string", teamName: "string" },
  channels: [...],
  description: "Sent when a user is invited to a team",
  category: "social",
  required: true,
  redact: ["inviterName"],
  rateLimit: { max: 5, windowMs: 60_000 },
  fallback: inbox({ title: "You have a team invite" }),
})

Which options do I need?

Walk through these questions for each new notification. Most only need the first two — add complexity when you observe problems, not upfront.

Must it always deliver?

Password resets, 2FA, receipts → set required: true. This bypasses user preference opt-outs.

Can the same event fire rapidly?

Multiple likes, edits, or comments in seconds → add digest with a window (e.g. 5 min). Users get one email, not twenty.

Could a bad actor trigger it in a loop?

Any user-initiated event that targets another user → add rateLimit. Caps sends per recipient per window.

Is delivery failure unacceptable?

Critical alerts where the user must see it → add fallback to a secondary channel (usually inbox).

ScenarioOptions to setExample notification
Simple activity alertNone — defaults are fineTask assigned, team invite
Transactional emailrequired: truePassword reset, payment receipt
High-frequency socialdigest + rateLimitPost liked, new follower
Critical system alertrateLimit + fallbackUsage limit warning, incident alert
Noisy + criticaldigest + rateLimit + fallbackError spike alert, security events
Start with zero options. Ship the notification with just id, payload, and channels. Add rateLimit when you see spam, digest when users complain about noise, and fallback for paths where delivery failures cause support tickets.

Modeling your notifications

Not sure what notifications to create? Start by listing every event where a user should know something happened. Then categorize:

CategoryExamplesTypical config
TransactionalPassword reset, 2FA code, payment receiptrequired: true, email only, no digest
ActivityComment mention, team invite, task assignedInbox + email, dedup by entity, user can opt out
SocialNew follower, post liked, reaction addedInbox + digest (batch by window), email off by default
SystemDeploy succeeded, usage limit warning, incident alertWebhook + inbox, rate-limited, with fallback
One notification per user-visible event. Don't create comment_created_inbox and comment_created_email separately — use one definition with multiple channels. The user controls which channels fire via preferences.

Payload design patterns

The payload is the contract between your send() call and your channel templates. Structure it around what the recipient needs to understand — not around your internal data model.

Actor + target

Someone did something to something. Comments, mentions, reviews, assignments.

Status change

Something changed state. Orders, deployments, approvals, workflow transitions.

Content preview

Someone created content you should see. Messages, posts, document edits.

Threshold alert

A metric crossed a boundary. Usage limits, latency spikes, budget caps.

Action required

The recipient must do something by a deadline. Approvals, reviews, renewals.

PatternShapeUse forTemplate example
Actor + targetactorName, targetName, targetUrlSomeone did something to something{{actorName}} commented on {{targetName}}
Status changeentityName, oldStatus, newStatus, entityUrlSomething changed state{{entityName}} moved to {{newStatus}}
Content previewactorName, preview, contentUrlSomeone created content you should see{{actorName}}: "{{preview}}"
Threshold alertmetricName, currentValue, threshold, dashboardUrlA metric crossed a boundary{{metricName}} hit {{currentValue}} (limit: {{threshold}})
Action requiredactionLabel, deadline, actionUrlRecipient must do something by a dateAction needed: {{actionLabel}} by {{deadline}}
// Actor + target: "Rey commented on Launch Plan"
notification({
  id: "comment_added",
  payload: {
    actorName: "string",
    targetName: "string",
    targetUrl: "string",
    preview: "string",
  },
  channels: [
    inbox({
      title: "{{actorName}} commented on {{targetName}}",
      body: "{{preview}}",
      actionUrl: "{{targetUrl}}",
    }),
  ],
})

// Status change: "Order #1234 shipped"
notification({
  id: "order_status_changed",
  payload: {
    orderNumber: "string",
    newStatus: "string",
    trackingUrl: "string",
  },
  channels: [
    inbox({
      title: "Order #{{orderNumber}} {{newStatus}}",
      actionUrl: "{{trackingUrl}}",
    }),
    email({
      subject: "Your order #{{orderNumber}} is now {{newStatus}}",
      body: "Track your order: {{trackingUrl}}\n\nUnsubscribe: {{_unsubscribeUrl}}",
    }),
  ],
})

// Threshold alert: "API latency hit 2400ms (limit: 2000ms)"
notification({
  id: "metric_threshold",
  payload: {
    metricName: "string",
    currentValue: "string",
    threshold: "string",
    dashboardUrl: "string",
  },
  channels: [
    inbox({
      title: "{{metricName}} exceeded threshold",
      body: "Current: {{currentValue}} (limit: {{threshold}})",
      actionUrl: "{{dashboardUrl}}",
    }),
  ],
})
Include a URL in every payload. The actionUrl / targetUrl / dashboardUrl gives the user somewhere to go. Without it, the notification tells them something happened but doesn't help them act on it.

Payload field naming conventions

ConventionExampleWhy
Use display-ready valuesactorName: "Rey Saluja" not actorId: "usr_123"Templates render directly — no lookup possible at render time
Include URLs, not entity IDspostUrl: "/posts/42" not postId: "42"Channels need a link destination, not a raw identifier
Use string for numbers in templatesamount: "string" → pass "$12.99"Formatting (currency, locale) should happen at send time, not in the template
Keep payloads flatactorName, actorAvatar not actor: { name, avatar }Inline schemas only support flat keys. Nested requires Zod.
Prefix internal fields with contextinvoiceId not just idAvoid collisions when payloads are logged or composed in digests
Don't store raw IDs as your only reference. If you pass actorId: "usr_123" without actorName, your templates can't show a human-readable name. render() functions can't be async — they can't look up names from a database. Resolve everything at send time.

Complete example

A production notification combining multiple features — rate limiting, digest, fallback, category, and redaction — all in one definition:

export const commentMentioned = notification({
  id: "comment_mentioned",
  description: "Someone mentioned you in a comment",
  category: "activity",

  payload: {
    actorName: "string",
    actorEmail: "string",
    postTitle: "string",
    postUrl: "string",
    commentPreview: "string",
  },

  channels: [
    inbox({
      title: "{{actorName}} mentioned you",
      body: "{{commentPreview}}",
      actionUrl: "{{postUrl}}",
    }),
    email({
      subject: "{{actorName}} mentioned you in {{postTitle}}",
      body: "{{commentPreview}}\n\nOpen {{postUrl}} to reply.\n\nUnsubscribe: {{_unsubscribeUrl}}",
    }),
  ],

  // Don't spam — max 30 mentions per hour per recipient
  rateLimit: { max: 30, windowMs: 60 * 60_000 },

  // Batch rapid-fire mentions into one email
  digest: {
    windowMs: 5 * 60_000,
    key: ({ payload }) => payload.postUrl,
    render: ({ payloads, count }) => ({
      actorName: payloads[payloads.length - 1]!.actorName,
      actorEmail: payloads[payloads.length - 1]!.actorEmail,
      postTitle: payloads[0]!.postTitle,
      postUrl: payloads[0]!.postUrl,
      commentPreview: count > 1
        ? `${count} new mentions`
        : payloads[0]!.commentPreview,
    }),
  },

  // If email fails, at least show it in the inbox
  fallback: inbox({
    title: "{{actorName}} mentioned you (email delivery failed)",
    actionUrl: "{{postUrl}}",
  }),

  // Strip PII from logs and timeline
  redact: ["actorEmail"],
})
Feature usedWhy
categoryGroups with other activity notifications in the preferences UI
rateLimitPrevents a spam attack from flooding a user's inbox
digestIf Rey mentions you 5 times in 5 minutes, you get one email, not five
fallbackEmail delivery can fail — the user still sees it in-app
redactActor email appears in logs as [REDACTED] — only needed for delivery
Start simple, add features as you need them. Most notifications only need id, payload, and channels. Add rate limiting when you observe spam, digests when users complain about noise, and fallbacks for critical paths. Don't over-engineer on day one.

Evolving definitions over time

Notification definitions live in code and evolve with your app. Use this quick matrix to assess changes before you deploy:

Safe — deploy freely

Change template text, add an optional payload field, add or remove a channel, tweak rateLimit / digest values.

Risky — review first

Remove a payload field (search all send() sites), delete a notification (wait for in-flight to drain).

Breaking — requires migration

Rename a notification ID (preferences, dedup keys, and rate limit counters reference the old one).

ChangeSafe?RiskMitigation
Add a payload fieldYes (if optional)Old sends missing the field render {{newField}} as emptyUse a default in your template: {{newField}} renders blank gracefully, or use render() with a fallback
Remove a payload fieldRiskyOld templates referencing removed field render empty; callers passing it get a TS errorRemove from schema and template in the same deploy. Search for all send() calls first.
Rename a notification IDBreakingExisting preferences, rate limit counters, and dedup keys reference the old IDCreate a new ID, migrate preferences with a script, then remove the old one
Change a template stringYesExisting inbox items keep the old rendered text (it's stored at write time)No migration needed — only new sends use the updated template
Add a channelYesUsers who didn't opt out will start receiving it immediatelySet defaultChannels: { newChannel: false } to make it opt-in at first
Remove a channelYesUsers who had preferences set for that channel keep stale preference rowsHarmless — stale preferences are ignored. Clean up optionally.
Delete a notification entirelyCarefulInbox items for old sends still exist; preferences become orphanedRemove from the array. Old inbox items remain visible (they're just data). Clean up if needed.

Adding fields safely

// BEFORE: original definition
notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postUrl: "string" },
  channels: [
    inbox({ title: "{{actorName}} mentioned you" }),
  ],
})

// AFTER: added commentPreview (optional in template)
notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postUrl: "string", commentPreview: "string" },
  channels: [
    inbox({
      title: "{{actorName}} mentioned you",
      body: "{{commentPreview}}",  // renders empty if old sends didn't have it
    }),
  ],
})

Renaming a notification (migration)

// Step 1: Create the new definition alongside the old one
export const commentReply = notification({
  id: "comment_reply",  // new name
  payload: { actorName: "string", postUrl: "string", commentPreview: "string" },
  channels: [...],
})

// Step 2: Migrate preferences from old ID to new ID
const allPrefs = await db
  .select()
  .from(notifyKitSchema.preferences)
  .where(eq(notifyKitSchema.preferences.notificationId, "comment_mentioned"))

for (const pref of allPrefs) {
  await notify.preferences.update({
    recipientId: pref.recipientId,
    notificationId: "comment_reply",
    channels: pref.channels,
  })
}

// Step 3: Update all send() call sites to use the new ID
// Step 4: Remove the old definition from the notifications array
Templates are rendered at send time, not read time. When you change a template string, existing inbox items keep their original text — only future sends use the new template. This means template changes are always safe to deploy without migration.

Deprecating gracefully

When removing a notification, consider existing subscribers and in-flight state:

1
Stop sending

Remove all send() calls for this notification from your code.

2
Wait for in-flight to drain

If using digests or quiet hours, wait for flushDigests() and flushScheduledSends() to clear buffered sends.

3
Remove the definition

Delete from the notifications array. The preferences UI stops showing it.

4
Clean up (optional)

Delete orphaned preferences and old inbox items if they clutter your database.

Don't reuse deleted IDs. If you delete "comment_mentioned" and later create a new notification with the same ID, old preference rows will apply to the new notification — users who opted out of the old one will be opted out of the new one. Use a fresh ID.

Registering definitions

Pass all notification definitions to createNotifyKit(). The as const assertion is required for full type inference:

import { createNotifyKit } from "@notifykitjs/core"
import { commentMentioned } from "./notifications/comment-mentioned"
import { orderShipped } from "./notifications/order-shipped"
import { teamInvite } from "./notifications/team-invite"

export const notify = createNotifyKit({
  notifications: [commentMentioned, orderShipped, teamInvite] as const,
  // ...
})

Now notify.send() only accepts valid notification IDs and the correct payload shape for each.

Safe schema evolution

Once a notification is in production, changing its payload schema requires care. Buffered digests, queued deliveries, and in-flight sends may still carry the old shape. Follow these rules to evolve safely:

ChangeSafe?WhyMigration needed
Add an optional fieldYesOld sends pass validation (field is optional). Templates render it as empty string if absent.None
Add a required fieldNoIn-flight sends and buffered digests lack the field — validation fails on flush.Add as optional first, backfill all callers, then make required in a follow-up deploy
Remove a fieldYes (if templates updated)Old queued sends may still include it (harmless — extra fields are ignored). Danger is templates referencing a now-missing field.Remove from templates first, then from schema
Rename a fieldNoEquivalent to adding required + removing old. In-flight sends break.Add new field (optional), update callers, update templates, remove old field
Change a field's typeNoOld sends carry the old type — validation or template rendering breaks.Add a new field with the new type, migrate callers, then remove the old field
Digests are the hidden danger. A 24-hour digest window means payloads from yesterday are flushed with today's schema. If you added a required field between those sends, the render() function receives payloads that lack it. Always guard with optional chaining or defaults inside render().

Safe field addition (two-deploy pattern)

// Deploy 1: add as optional, handle absence in templates
notification({
  id: "order_shipped",
  payload: {
    orderNumber: "string",
    trackingUrl: "string",
    carrier: "string?",     // new optional field
  },
  channels: [
    inbox({
      render: (p) => ({
        title: `Order ${p.orderNumber} shipped`,
        body: p.carrier
          ? `Shipped via ${p.carrier}`
          : "Your order is on the way",
      }),
    }),
  ],
  digest: {
    windowMs: 60 * 60_000,
    render: ({ payloads, count }) => ({
      orderNumber: payloads[payloads.length - 1]!.orderNumber,
      trackingUrl: payloads[payloads.length - 1]!.trackingUrl,
      carrier: payloads[payloads.length - 1]?.carrier ?? undefined,
    }),
  },
})

// Deploy 2 (after all callers pass carrier): promote to required
// payload: { orderNumber: "string", trackingUrl: "string", carrier: "string" }
1
Deploy: add optional field

Schema accepts both old (without field) and new (with field) payloads. Templates handle absence gracefully.

2
Update all callers

Every send() call now passes the new field. Verify with grep — no caller should omit it.

3
Wait for in-flight to drain

Wait at least as long as your longest digest window + queue retry delay. All old-shape payloads flush.

4
Deploy: make required

Now safe — no in-flight payloads lack the field. TypeScript enforces it at compile time from here on.

The wait in step 3 equals your longest digest window. If your longest digest is 1 hour, wait 1 hour after deploy 2 before deploy 3. If you use setTimeoutQueue() with 5 retry attempts at exponential backoff, add ~30 seconds for the retry tail. For inlineQueue(), there's no wait — sends resolve synchronously.

File organization

One notification per file keeps things manageable as your count grows. Scale your structure to match your notification count:

ScalePatternFile structure
1–5 notificationsAll in one filelib/notifykit.ts — definitions + instance together
5–20 notificationsOne file per notificationlib/notifications/comment-mentioned.ts, order-shipped.ts, etc.
20+ notificationsGrouped by domain + barrellib/notifications/activity/, billing/, social/ with index.ts per folder
// lib/notifications/index.ts — barrel export for all definitions
export { commentMentioned } from "./activity/comment-mentioned"
export { taskAssigned } from "./activity/task-assigned"
export { newFollower } from "./social/new-follower"
export { invoicePaid } from "./billing/invoice-paid"
export { passwordReset } from "./billing/password-reset"

// lib/notifykit.ts — single import, wire up
import * as notifications from "./notifications"

export const notify = createNotifyKit({
  notifications: Object.values(notifications) as const,
  database: drizzlePostgresAdapter(db),
  providers: { email: resendProvider({ ... }) },
})
One file per notification is the sweet spot. Each file is self-contained (payload, channels, options), easy to find by ID, and gives clean git blame. Co-locate definitions with the feature that sends them — when a feature is deleted, its notification goes with it.

Shared channel builders

When multiple notifications share the same email structure or inbox format, extract a builder to avoid repetition:

// lib/notifications/_shared.ts
import { channel } from "@notifykitjs/core"

const inbox = channel.inbox()
const email = channel.email()

export function actorInbox(template: { action: string; target: string }) {
  return inbox({
    title: `{{actorName}} ${template.action}`,
    body: `In ${template.target}`,
    actionUrl: "{{actionUrl}}",
  })
}

export function transactionalEmail(subject: string) {
  return email({
    subject,
    body: `${subject}\n\n{{body}}\n\n---\nUnsubscribe: {{_unsubscribeUrl}}`,
  })
}

// lib/notifications/comment-mentioned.ts
import { notification } from "@notifykitjs/core"
import { actorInbox, transactionalEmail } from "./_shared"

export const commentMentioned = notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postTitle: "string", actionUrl: "string", body: "string" },
  channels: [
    actorInbox({ action: "mentioned you", target: "{{postTitle}}" }),
    transactionalEmail("{{actorName}} mentioned you in {{postTitle}}"),
  ],
})

When to split vs combine

A common question: should "comment mentioned" and "comment replied" be one notification or two? Use this decision framework:

QuestionIf yes → splitIf no → combine
Do users want separate preference toggles?"Mentions" and "Replies" as separate rows in settingsOne "Comments" toggle covers both
Are the payloads meaningfully different?A mention has postUrl, a deploy has buildId + logsBoth carry actorName + targetUrl
Would they have different rate limits or digests?Mentions digest at 5min, replies deliver immediatelySame noise profile, same delivery rules
Do different channels apply?Mentions → inbox + email, deploys → webhook onlyBoth go to inbox + email
When in doubt, split. It's trivial to merge two notifications later (combine into one ID, update send calls). Splitting one into two is harder — you need to migrate existing preference rows and update every send call site.

Testing your definitions

Notification definitions are the contract between your app and your users. Test them the same way you'd test any API contract — verify that the right channels fire, templates render correctly, and invalid payloads are rejected.

What to testWhyCatches
Template renderingVerifies payload fields appear where expectedTypos in {{field}} names, missing fields rendering blank
Channel selectionConfirms the right channels fire for each notificationAccidentally removing a channel, or adding one users don't expect
Payload validationEnsures malformed data is rejected at send timeMissing required fields, wrong types, schema drift
Digest renderingConfirms batched payloads merge into a coherent messageBroken render() when count is 1 vs many, undefined access
Rate limit behaviorProves the cap is enforced at the expected thresholdRate limit misconfigured (too high or too low)

Pattern: definition smoke tests

import { describe, it, expect } from "vitest"
import { createNotifyKit, memoryAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { commentMentioned, orderShipped } from "./notifications"

function setup() {
  return createNotifyKit({
    notifications: [commentMentioned, orderShipped] as const,
    database: memoryAdapter(),
    providers: { email: fakeEmailProvider() },
  })
}

describe("notification definitions", () => {
  it("comment_mentioned renders actor name in inbox title", async () => {
    const notify = setup()
    await notify.upsertRecipient({ id: "u1", email: "test@test.com" })

    const result = await notify.send({
      recipientId: "u1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
    })

    expect(result.inboxItems[0].title).toBe("Rey mentioned you")
    expect(result.inboxItems[0].body).toBe("In Launch")
    expect(result.inboxItems[0].actionUrl).toBe("/p/1")
  })

  it("comment_mentioned delivers to both inbox and email", async () => {
    const notify = setup()
    await notify.upsertRecipient({ id: "u1", email: "test@test.com" })

    const result = await notify.send({
      recipientId: "u1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1" },
    })

    expect(result.inboxItems).toHaveLength(1)
    expect(result.deliveries.find(d => d.channel === "email")).toBeDefined()
  })

  it("rejects invalid payload (missing required field)", async () => {
    const notify = setup()
    await notify.upsertRecipient({ id: "u1", email: "test@test.com" })

    const result = await notify.send({
      recipientId: "u1",
      notificationId: "comment_mentioned",
      // @ts-expect-error — testing runtime validation
      payload: { actorName: "Rey" },
    })

    expect(result.skipped).toBe(true)
    expect(result.skipReason).toBe("invalid_payload")
  })
})

Testing digest rendering

Digests are the trickiest part of a definition to get right — the render() function must handle both the single-event and multi-event case gracefully:

describe("comment_mentioned digest", () => {
  it("renders single event as normal message", async () => {
    const notify = setup()
    await notify.upsertRecipient({ id: "u1", email: "test@test.com" })

    await notify.send({
      recipientId: "u1",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postTitle: "Launch", postUrl: "/p/1", commentPreview: "looks good!" },
    })

    // Flush the digest window
    const flushed = await notify.flushDigests()
    expect(flushed[0].inboxItems[0].body).toBe("looks good!")
  })

  it("renders multiple events as count summary", async () => {
    const notify = setup()
    await notify.upsertRecipient({ id: "u1", email: "test@test.com" })

    // Send 3 events within the digest window
    for (const actor of ["Rey", "Sam", "Alex"]) {
      await notify.send({
        recipientId: "u1",
        notificationId: "comment_mentioned",
        payload: { actorName: actor, postTitle: "Launch", postUrl: "/p/1", commentPreview: "..." },
      })
    }

    const flushed = await notify.flushDigests()
    expect(flushed[0].inboxItems[0].body).toBe("3 new mentions")
  })
})

Testing rate limits on definitions

describe("comment_mentioned rate limit", () => {
  it("enforces max 30 sends per hour", async () => {
    const notify = setup()
    await notify.upsertRecipient({ id: "u1", email: "test@test.com" })

    // Send up to the limit
    for (let i = 0; i < 30; i++) {
      const result = await notify.send({
        recipientId: "u1",
        notificationId: "comment_mentioned",
        payload: { actorName: `User ${i}`, postTitle: "Post", postUrl: "/p/1" },
      })
      expect(result.skipped).toBeFalsy()
    }

    // 31st send should be rate-limited
    const blocked = await notify.send({
      recipientId: "u1",
      notificationId: "comment_mentioned",
      payload: { actorName: "One More", postTitle: "Post", postUrl: "/p/1" },
    })

    expect(blocked.skipped).toBe(true)
    expect(blocked.skipReason).toBe("rate_limited")
  })
})
Test levelWhat it provesI/O boundary
Template renderingPayload fields map to the right template slotsIn-memory, no provider or database
Channel deliveryCorrect channels fire (inbox, email, SMS)Fake provider, no network
Validation rejectionMalformed payloads fail gracefully with a reasonIn-memory validation
Digest renderingSingle and multi-event render() both workIn-memory buffer and renderer
Rate limitCap is enforced at the configured thresholdIn-memory loop; duration depends on the configured limit
Run definition tests on every PR. They're the fastest tests in your suite (in-memory adapter, fake providers, no network) and catch the most embarrassing bugs — a typo in a template field means users see blank notifications in production. One test file per notification keeps coverage proportional to your notification count.