Handler routes

The handler (createHandler / createRouteHandler) exposes a REST API that the React client consumes. This page documents every route.

Single catch-all route

One file serves inbox, preferences, events, unsubscribe, and webhooks. No manual routing.

Identity-scoped

Every request resolves the user via your identify() callback. Cross-tenant access is impossible.

SSE realtime stream

The /inbox/stream endpoint pushes inbox updates to the client — no polling or WebSocket setup.

Optimistic SDK

The React SDK calls these routes automatically with optimistic updates and automatic error recovery.

Mounting the handler

Before the routes exist, you need to wire the handler into your framework. Here are the two most common setups:

app/api/notifykit/[...notifykit]/route.ts
import { createRouteHandler } from "@notifykitjs/next"
import { notify } from "@/lib/notifykit"
import { auth } from "@/lib/auth"

export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
  notifykit: notify,
  identify: async (request) => {
    const session = await auth(request)
    if (!session) return null
    return { recipientId: session.userId }
  },
})
src/routes/notifykit.ts
import { createHandler } from "@notifykitjs/core"
import { notify } from "../lib/notifykit"

const handler = createHandler(notify, {
  identify: async (req) => {
    const user = req.user
    if (!user) return null
    return { recipientId: user.id }
  },
})

app.all("/api/notifykit/*", handler)
The catch-all route is intentional. A single route file serves all NotifyKit endpoints — inbox, preferences, realtime, and webhooks. The handler does its own path matching internally.

Route overview

MethodPathAuthPurpose
GET/inboxRequiredList inbox items
POST/inbox/:id/readRequiredMark read
POST/inbox/mark-all-readRequiredMark all read
POST/inbox/:id/archiveRequiredArchive item
POST/inbox/:id/unarchiveRequiredUnarchive item
DELETE/inbox/:idRequiredDelete item
GET/preferencesRequiredList preferences
POST/preferencesRequiredUpdate preference
GET/notificationsOptionalList registered definitions
GET/deliveriesAdminList delivery records
GET/unsubscribeTokenEmail unsubscribe (human click)
POST/unsubscribeTokenRFC 8058 one-click unsubscribe
GET/inbox/streamRequiredSSE realtime stream
POST/webhooks/:providerSignatureInbound provider webhooks
You rarely call these directly. The React SDK and useInbox() / usePreferences() hooks call these routes automatically. This reference is for debugging, custom clients, or non-React integrations.

Request lifecycle

Every request to the handler follows the same flow. Understanding it helps you debug auth issues and add custom middleware:

1
CORS check

If cors is configured, preflight OPTIONS returns headers immediately. Invalid origins get no CORS headers (browser blocks).

2
Rate limit

If requestRateLimit is set, checks the sliding window. Over-limit → 429 with Retry-After header.

3
identify()

Your function resolves the user from cookies/headers. Returns null401. Returns scope object → continues.

4
authorize()

Optional fine-grained check. Called with the resolved identity + the requested permission. Returns false403.

5
Route handler

Dispatches to the matched route (inbox, preferences, etc). Scoped by the identity — cross-tenant access is impossible.

Stage fails atResponseClient SDK behavior
CORSNo response (browser blocks)Network error in DevTools — check origin config
Rate limit429 + Retry-AfterSDK backs off automatically, retries after delay
identify()401SDK stops retrying — surface a login prompt
authorize()403SDK surfaces the error — user lacks permission
Route handler404 / 400 / 500SDK reverts optimistic updates, surfaces error state
The unsubscribe route skips identify(). It uses the HMAC token in the URL for auth instead — email clients can't send cookies. Same for inbound webhook routes, which verify provider signatures.

Authorization patterns

identify() answers "who is this?" The optional authorize() callback answers "can they do this?" Use it when different users have different access levels:

Patternidentify() returnsauthorize() checksUse case
Basic (most apps){ recipientId }Not needed — handler auto-scopes by recipientSingle user accessing their own inbox/preferences
Multi-tenant{ recipientId, tenantId }Not needed — scoping prevents cross-tenant accessSaaS with organizations
Admin access{ recipientId, permissions: ["admin"] }Built-in permission allows cross-user delivery inspectionSupport dashboards, admin panels
Delivery viewer{ recipientId, permissions: ["deliveries.list"] }Allow access to delivery records, scoped to the current userDelivery history and debugging views
app/api/notifykit/[...notifykit]/route.ts
createRouteHandler({
  notifykit: notify,
  identify: async (request) => {
    const session = await auth(request)
    if (!session) return null
    return {
      recipientId: session.userId,
      tenantId: session.orgId,
      permissions: session.isAdmin ? ["admin"] : [],
    }
  },
  authorize: async (ctx, permission) => {
    return permission === "deliveries.list" && ctx.identity.permissions?.includes("admin") === true
  },
})

Permission values

The authorize() callback receives a permission string for protected delivery/admin operations. User-scoped inbox and preference routes enforce ownership directly and do not call it:

RoutePermissionTypical rule
GET /deliveriesdeliveries.listRead delivery records; non-admin access stays recipient-scoped
Cross-user deliveriesadminAllow the recipientId query parameter to target another user
You don't need authorize() for most apps. The handler already enforces ownership — a user can only read or modify their own inbox items and preferences. authorize() is specifically for delivery-history and admin access.

Configuration

lib/handler.ts
import { createHandler } from "@notifykitjs/core"

const handler = createHandler(notify, {
  identify: async (request) => getIdentity(request),
  authorize: async (ctx, permission) => permission === "deliveries.list",
  unsubscribeSecret: process.env.NOTIFYKIT_SECRET,
  cors: "https://app.example.com",
  protectNotifications: true,
  requestRateLimit: { max: 120, windowMs: 60_000 },
})
OptionRequiredDefaultPurpose
identifyYesResolves the current user from the request. Return null to reject (401). Return an object with at minimum recipientId.
authorizeNoAllow allPermission check for delivery-history and admin operations. Return false to reject (403).
unsubscribeSecretNoHMAC secret for verifying email unsubscribe links. It must match the secret passed to createNotifyKit().
corsNoDisabledAllowed origin for CORS preflight. Pass one origin string or "*"; omit to disable CORS headers.
protectNotificationsNofalseWhen true, the GET /notifications route requires authentication. Useful if notification definitions are sensitive.
requestRateLimitNoDisabledPer-identity sliding window. max: requests allowed per window. windowMs: window duration in ms.
webhooksNoSignature verification functions keyed by provider name. Called on POST /webhooks/:provider. Return false to reject (401).
onWebhookEventNoCallback fired after a webhook passes signature verification. Use for processing delivery status updates, bounces, or opens.
Start with just identify. Every other option has a sensible default. Add cors when your frontend is on a different origin, requestRateLimit when you go to production, and authorize only if you need admin-level routes.

Inbox routes

RouteReturnsNotes
GET /inboxInboxItem[]Query params: ?archived (boolean), ?limit (number)
GET /inbox/unread-count{ count }Fetches the badge count without loading inbox items
POST /inbox/:id/readInboxItem403 if item belongs to another user
POST /inbox/mark-all-read{ count }Returns number of items marked
POST /inbox/:id/archiveInboxItemSets archivedAt
POST /inbox/:id/unarchiveInboxItemClears archivedAt
DELETE /inbox/:id{ deleted: true }Permanent — cannot be undone

Preference routes

RouteReturnsNotes
GET /preferencesRecipientPreference[]All preferences for the authenticated user
POST /preferencesRecipientPreferenceUpserts — creates if not exists
{
  "notificationId": "comment_mentioned",
  "channels": { "email": false }
}

Other routes

RouteAuthReturnsNotes
GET /notificationsOptionalNotification metadata arrayPublic by default. Set protectNotifications: true to require auth.
GET /deliveriesAdminDeliveryRecord[]Sensitive fields (body, subject, to) are redacted. Non-admins see only own records.

Unsubscribe, realtime & webhooks

RouteAuthPurpose
GET /unsubscribe?token=...HMAC tokenHuman click from email — verifies token, disables email, renders confirmation
POST /unsubscribeHMAC tokenRFC 8058 one-click (mail client header). Same verification, returns 200.
GET /inbox/streamRequiredSSE stream — inbox mutations in real time. React client connects automatically.
POST /webhooks/:providerSignatureInbound provider webhooks (delivery status, bounces, opens). 401 on invalid sig.

Error responses

Errors always include a human-readable error. Structured failures can also include code, and development responses include an actionable fix only when debug is enabled:

{ "error": "Inbox item not found", "code": "NOT_FOUND" }
Statuserror valueWhen
400bad_requestMissing or malformed request body / query params
401unauthorizedidentify() returned null — no valid session
403forbiddenAuthenticated but not authorized (cross-tenant access, missing permission)
404not_foundResource doesn't exist or isn't owned by this user
429rate_limitedrequestRateLimit exceeded — includes Retry-After header
500internal_errorUnhandled error — check server logs
The React SDK handles these for you. On 401 it stops retrying. On 429 it backs off automatically. On 4xx mutations it reverts optimistic updates. You only need this reference when building a custom client or debugging with the network tab.

Request & response examples

Complete examples for debugging in the terminal or building non-React clients. All paths are relative to your handler base (e.g. /api/notifykit).

# List inbox items (most recent first)
curl -s http://localhost:3000/api/notifykit/inbox?limit=5 \
  -H "Cookie: session=..." | jq .
[
  {
    "id": "inb_a1b2c3",
    "notificationId": "comment_mentioned",
    "recipientId": "user_123",
    "title": "Rey mentioned you",
    "body": "In Launch Plan",
    "actionUrl": "/posts/42",
    "readAt": null,
    "archivedAt": null,
    "createdAt": "2025-03-15T10:30:00.000Z"
  }
]
# Mark an item as read
curl -s -X POST http://localhost:3000/api/notifykit/inbox/inb_a1b2c3/read \
  -H "Cookie: session=..."
{
  "id": "inb_a1b2c3",
  "title": "Rey mentioned you",
  "readAt": "2025-03-15T10:31:22.000Z"
}
# Update preferences (disable email for a notification)
curl -s -X POST http://localhost:3000/api/notifykit/preferences \
  -H "Cookie: session=..." \
  -H "Content-Type: application/json" \
  -d '{"notificationId": "comment_mentioned", "channels": {"email": false}}'
{
  "recipientId": "user_123",
  "notificationId": "comment_mentioned",
  "channels": { "inbox": true, "email": false },
  "updatedAt": "2025-03-15T10:32:00.000Z"
}
# List registered notifications (public metadata)
curl -s http://localhost:3000/api/notifykit/notifications | jq .
[
  {
    "id": "comment_mentioned",
    "channels": ["inbox", "email"],
    "category": "activity",
    "description": "Someone mentioned you in a comment",
    "required": false
  },
  {
    "id": "password_reset",
    "channels": ["email"],
    "category": "billing",
    "description": "Password reset link",
    "required": true
  }
]
Pipe to jq for readable output. All responses are JSON. Use jq . for pretty printing, or jq '.[0].title' to extract specific fields when scripting against the API.

CORS & rate limiting

OptionEffect
cors: "https://app.com"All routes respond to OPTIONS with configured CORS headers
requestRateLimit: { max, windowMs }Per-identity sliding window on authenticated routes. Exceeding returns 429.
Unauthenticated routes (notifications, unsubscribe) are not throttled by requestRateLimit. Apply IP-based limiting at your reverse proxy for those.

Building a custom client

The React SDK calls these routes for you. If you're building a mobile app, a Vue/Svelte frontend, or a backend-to-backend integration, call them directly:

lib/notifykit-client.ts
const BASE = "https://app.com/api/notifykit"

async function notifyFetch(path, opts = {}) {
  const res = await fetch(`${BASE}${path}`, {
    headers: {
      "Content-Type": "application/json",
      ...opts.headers,
    },
    credentials: "include",
    ...opts,
  })
  if (!res.ok) {
    const err = await res.json()
    throw new Error(`${res.status}: ${err.error}`)
  }
  const body = await res.json()
  return body.data
}

// Inbox operations
const items = await notifyFetch("/inbox?limit=20")
await notifyFetch("/inbox/inb_abc/read", { method: "POST" })
await notifyFetch("/inbox/mark-all-read", { method: "POST" })
await notifyFetch("/inbox/inb_abc", { method: "DELETE" })

// Preferences
const prefs = await notifyFetch("/preferences")
await notifyFetch("/preferences", {
  method: "POST",
  body: JSON.stringify({
    notificationId: "comment_mentioned",
    channels: { email: false },
  }),
})

// SSE realtime (browser EventSource)
const events = new EventSource(`${BASE}/inbox/stream`, { withCredentials: true })
events.onmessage = (e) => {
  const data = JSON.parse(e.data)
  if (data.type === "inbox.created") addToList(data.item)
}
PlatformAuth approachSSE support
Web (same origin)Cookies via credentials: "include"Native EventSource
Web (cross-origin)Bearer token + CORS config on handlerNative EventSource (limited header support — use query param token)
React Native / mobileBearer token in Authorization headerUse react-native-sse or polyfill
Backend serviceService token or API key via custom identify()Not needed — poll /inbox or use hooks server-side
EventSource doesn't support custom headers. If your auth requires a bearer token (not cookies), pass it as a query param (/inbox/stream?token=...) and verify it in your identify() function. Never log these URLs.

SSE event reference

The GET /inbox/stream route streams Server-Sent Events to connected clients. Each SSE frame follows the standard wire format — an event: line, a data: line with JSON, and a blank line terminator:

event: inbox.created
data: {"type":"inbox.created","item":{"id":"inb_a1b2c3","notificationId":"comment_mentioned","recipientId":"user_123","title":"Rey mentioned you","readAt":null,"createdAt":"2025-03-15T10:30:00.000Z"}}

event: inbox.updated
data: {"type":"inbox.updated","item":{"id":"inb_a1b2c3","readAt":"2025-03-15T10:31:22.000Z"}}

: heartbeat
Event typePayloadWhen it fires
inbox.createdFull InboxItemNew notification delivered to this recipient's inbox
inbox.updatedFull updated InboxItemItem marked read, archived, or unarchived
inbox.deleted{ "type": "inbox.deleted", "itemId": "inb_..." }Item permanently deleted
inbox.all_read{ "type": "inbox.all_read", "count": 5 }Bulk mark-all-read action
heartbeatSSE comment (: heartbeat)Every 30 seconds — keeps the connection alive past proxies

Handling events in a custom client

lib/realtime-events.ts
const events = new EventSource(`${BASE}/inbox/stream`, { withCredentials: true })

events.addEventListener("inbox.created", (e) => {
  const { item } = JSON.parse(e.data)
  addToInbox(item)
  incrementUnreadCount()
})

events.addEventListener("inbox.updated", (e) => {
  const { item } = JSON.parse(e.data)
  updateInboxItem(item.id, item)
  if (item.readAt) decrementUnreadCount()
})

events.addEventListener("inbox.deleted", (e) => {
  const { itemId } = JSON.parse(e.data)
  removeFromInbox(itemId)
})

events.addEventListener("inbox.all_read", () => {
  markAllItemsRead(new Date())
  resetUnreadCount()
})

events.onerror = () => {
  // Browser retries automatically — no manual reconnect needed
}

Optimistic updates

The React SDK applies optimistic updates automatically — mark-read updates the UI instantly, then confirms with the server. If you're building a custom client, implement this pattern yourself to avoid perceived latency:

1
Update UI immediately

Set readAt / remove from list before the server responds.

2
Fire request

POST to the server in the background.

3
On success: confirm

Replace the optimistic state with the server's response (authoritative timestamps).

4
On failure: rollback

Revert to the previous state. Show a toast or error indicator.

lib/inbox-store.ts
function createInboxStore(baseUrl) {
  let items = []
  let unreadCount = 0
  const listeners = new Set()

  function notify() { listeners.forEach(fn => fn({ items, unreadCount })) }

  return {
    subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },

    setItems(newItems) {
      items = newItems
      unreadCount = items.filter(i => !i.readAt).length
      notify()
    },

    async markRead(itemId) {
      // 1. Snapshot for rollback
      const prev = items.map(i => ({ ...i }))
      const prevCount = unreadCount

      // 2. Optimistic update
      items = items.map(i =>
        i.id === itemId ? { ...i, readAt: new Date().toISOString() } : i
      )
      unreadCount = items.filter(i => !i.readAt).length
      notify()

      // 3. Confirm with server
      try {
        const res = await fetch(`${baseUrl}/inbox/${itemId}/read`, { method: "POST", credentials: "include" })
        if (!res.ok) throw new Error(res.statusText)
        const updated = await res.json()
        items = items.map(i => i.id === itemId ? updated : i)
        notify()
      } catch {
        // 4. Rollback on failure
        items = prev
        unreadCount = prevCount
        notify()
      }
    },

    async markAllRead() {
      const prev = items.map(i => ({ ...i }))
      items = items.map(i => ({ ...i, readAt: i.readAt ?? new Date().toISOString() }))
      unreadCount = 0
      notify()

      try {
        const res = await fetch(`${baseUrl}/inbox/mark-all-read`, { method: "POST", credentials: "include" })
        if (!res.ok) throw new Error(res.statusText)
      } catch {
        items = prev
        unreadCount = prev.filter(i => !i.readAt).length
        notify()
      }
    },

    async deleteItem(itemId) {
      const prev = items.map(i => ({ ...i }))
      items = items.filter(i => i.id !== itemId)
      unreadCount = items.filter(i => !i.readAt).length
      notify()

      try {
        const res = await fetch(`${baseUrl}/inbox/${itemId}`, { method: "DELETE", credentials: "include" })
        if (!res.ok) throw new Error(res.statusText)
      } catch {
        items = prev
        unreadCount = prev.filter(i => !i.readAt).length
        notify()
      }
    },
  }
}
OperationOptimistic behaviorRollback on failure
Mark readSet readAt instantly, decrement badgeClear readAt, restore badge count
Mark all readSet readAt on all items, zero the badgeRestore original readAt values and count
ArchiveRemove from visible list immediatelyRe-insert at original position
DeleteRemove from list, update countRe-insert item, restore count
SSE confirms or conflicts. If your SSE connection is active, you'll receive an inbox.updated event after the server processes the mutation. Use it to replace your optimistic state with the authoritative version — this handles the case where another tab or device made the same change.
Never optimistically delete — unless you can undo. DELETE is permanent on the server. If the request fails after you've removed the item from the UI, the user has no way to get it back. Either add a brief undo window (5 seconds before firing the request) or show a confirmation first.

Bounded inbox lists

Use ?limit to cap the number of newest items returned and?archived=true to load the archived view separately. Cursor pagination is not part of the handler API yet.

ParamTypeDefaultPurpose
limitpositive integerAdapter defaultMaximum newest items to return
archivedbooleanfalseInclude only archived items when true
GET /api/notifykit/inbox?limit=20
GET /api/notifykit/inbox?archived=true&limit=20
Need deep history? Query your database adapter from trusted server code with your own cursor scheme, or keep the handler list deliberately bounded and offer separate active/archive views.

Troubleshooting

Handler issues usually surface as network errors in the browser or unexpected responses during development. Work through the table below — most problems resolve within the first three rows.

SymptomLikely causeFix
CORS error in browser consolecors not configured, or origin doesn't matchSet cors: "http://localhost:3000" (exact origin — no trailing slash, no wildcard in prod)
All requests return 401identify() returning null — session not resolvingLog inside identify(). Common causes: cookie not sent (credentials: "include" missing), auth middleware not running on this route.
404 on all handler routesCatch-all route not matching, or wrong path prefixVerify file is at app/api/notifykit/[...notifykit]/route.ts. The segment name must match the SDK's base path.
Inbox returns empty but sends succeedHandler identify() returns a different recipientId than what was passed to send()Ensure the ID from your auth session matches the ID you pass to send() exactly (case-sensitive).
Preferences save but don't affect deliveryPreference written without tenantId, but sends include one (or vice versa)The scopes must match. If your sends pass tenantId, your handler must return it from identify() too.
SSE connects then drops instantlyResponse buffering by proxy, CDN, or middlewareDisable buffering for the /inbox/stream route. On Vercel: use export const dynamic = "force-dynamic". On nginx: proxy_buffering off.
SSE works locally but not in productionLoad balancer idle timeout shorter than heartbeat intervalThe handler sends a heartbeat every 30 seconds. Set the load balancer idle timeout above that interval; see Realtime.
Unsubscribe link returns 401unsubscribeSecret not set on the handler, or env var is empty/missing in this environmentVerify process.env.NOTIFYKIT_SECRET is set. The unsubscribe route uses HMAC verification, not session auth — it needs the secret.
Mark-read returns 403Inbox item belongs to a different (recipient, tenant) pair than what identify() resolvedThe handler enforces ownership. Check that the user's session matches the tenant context they're operating in.
Request works in Postman but not the browserCookies not sent (missing credentials: "include") or CORS blocking preflightAdd credentials: "include" to fetch calls. Ensure the handler's cors allows the browser's origin.
Debug with curl first. If curl works but the browser doesn't, the issue is CORS or cookies. If curl also fails, the issue is server-side (auth, routing, handler config). This narrows the search space immediately.

Diagnostic checklist

Run through these checks in order when the handler isn't working. Each step eliminates one failure category:

1
Route exists

Hit GET /api/notifykit/notifications with curl. If 404: the catch-all route file isn't in the right location.

2
Auth resolves

Hit GET /api/notifykit/inbox with a valid session cookie. If 401: identify() isn't resolving your session.

3
Data exists

If 200 but empty: send a test notification via notify.send() and try again. Ensure recipientId matches.

4
Browser works

If curl succeeds but browser fails: add cors to the handler and credentials: "include" to the client.

# Step 1: Route exists?
curl -s http://localhost:3000/api/notifykit/notifications | jq .
# Expected: array of notification definitions (or 200 with [])

# Step 2: Auth resolves?
curl -s -H "Cookie: session=YOUR_SESSION_COOKIE" \
  http://localhost:3000/api/notifykit/inbox | jq .
# Expected: 200 with inbox items (or empty array)
# If 401: identify() returned null

# Step 3: SSE connects?
curl -N -H "Cookie: session=YOUR_SESSION_COOKIE" \
  http://localhost:3000/api/notifykit/inbox/stream
# Expected: ": heartbeat" within 30s

Connection lifecycle

1
Connect

Client opens GET /inbox/stream. Server runs identify() and holds the connection open.

2
Stream

Events push as mutations happen. Heartbeats fire every 30s to prevent proxy/load-balancer timeouts.

3
Disconnect

Network drops or tab closes. Browser EventSource auto-reconnects with Last-Event-ID.

4
Replay

Server replays missed events since the last ID. Client state catches up without a full refetch.

The React SDK handles all of this. useInbox() connects to SSE automatically, reconciles missed events on reconnect, and updates the component state. This reference is only needed for custom clients or debugging the event stream in DevTools.