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:
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 }
},
})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)Route overview
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /inbox | Required | List inbox items |
POST | /inbox/:id/read | Required | Mark read |
POST | /inbox/mark-all-read | Required | Mark all read |
POST | /inbox/:id/archive | Required | Archive item |
POST | /inbox/:id/unarchive | Required | Unarchive item |
DELETE | /inbox/:id | Required | Delete item |
GET | /preferences | Required | List preferences |
POST | /preferences | Required | Update preference |
GET | /notifications | Optional | List registered definitions |
GET | /deliveries | Admin | List delivery records |
GET | /unsubscribe | Token | Email unsubscribe (human click) |
POST | /unsubscribe | Token | RFC 8058 one-click unsubscribe |
GET | /inbox/stream | Required | SSE realtime stream |
POST | /webhooks/:provider | Signature | Inbound provider webhooks |
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:
If cors is configured, preflight OPTIONS returns headers immediately. Invalid origins get no CORS headers (browser blocks).
If requestRateLimit is set, checks the sliding window. Over-limit → 429 with Retry-After header.
Your function resolves the user from cookies/headers. Returns null → 401. Returns scope object → continues.
Optional fine-grained check. Called with the resolved identity + the requested permission. Returns false → 403.
Dispatches to the matched route (inbox, preferences, etc). Scoped by the identity — cross-tenant access is impossible.
| Stage fails at | Response | Client SDK behavior |
|---|---|---|
| CORS | No response (browser blocks) | Network error in DevTools — check origin config |
| Rate limit | 429 + Retry-After | SDK backs off automatically, retries after delay |
| identify() | 401 | SDK stops retrying — surface a login prompt |
| authorize() | 403 | SDK surfaces the error — user lacks permission |
| Route handler | 404 / 400 / 500 | SDK reverts optimistic updates, surfaces error state |
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:
| Pattern | identify() returns | authorize() checks | Use case |
|---|---|---|---|
| Basic (most apps) | { recipientId } | Not needed — handler auto-scopes by recipient | Single user accessing their own inbox/preferences |
| Multi-tenant | { recipientId, tenantId } | Not needed — scoping prevents cross-tenant access | SaaS with organizations |
| Admin access | { recipientId, permissions: ["admin"] } | Built-in permission allows cross-user delivery inspection | Support dashboards, admin panels |
| Delivery viewer | { recipientId, permissions: ["deliveries.list"] } | Allow access to delivery records, scoped to the current user | Delivery history and debugging views |
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:
| Route | Permission | Typical rule |
|---|---|---|
GET /deliveries | deliveries.list | Read delivery records; non-admin access stays recipient-scoped |
| Cross-user deliveries | admin | Allow the recipientId query parameter to target another user |
authorize() is specifically for delivery-history and admin access.Configuration
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 },
})| Option | Required | Default | Purpose |
|---|---|---|---|
identify | Yes | — | Resolves the current user from the request. Return null to reject (401). Return an object with at minimum recipientId. |
authorize | No | Allow all | Permission check for delivery-history and admin operations. Return false to reject (403). |
unsubscribeSecret | No | — | HMAC secret for verifying email unsubscribe links. It must match the secret passed to createNotifyKit(). |
cors | No | Disabled | Allowed origin for CORS preflight. Pass one origin string or "*"; omit to disable CORS headers. |
protectNotifications | No | false | When true, the GET /notifications route requires authentication. Useful if notification definitions are sensitive. |
requestRateLimit | No | Disabled | Per-identity sliding window. max: requests allowed per window. windowMs: window duration in ms. |
webhooks | No | — | Signature verification functions keyed by provider name. Called on POST /webhooks/:provider. Return false to reject (401). |
onWebhookEvent | No | — | Callback fired after a webhook passes signature verification. Use for processing delivery status updates, bounces, or opens. |
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
| Route | Returns | Notes |
|---|---|---|
GET /inbox | InboxItem[] | Query params: ?archived (boolean), ?limit (number) |
GET /inbox/unread-count | { count } | Fetches the badge count without loading inbox items |
POST /inbox/:id/read | InboxItem | 403 if item belongs to another user |
POST /inbox/mark-all-read | { count } | Returns number of items marked |
POST /inbox/:id/archive | InboxItem | Sets archivedAt |
POST /inbox/:id/unarchive | InboxItem | Clears archivedAt |
DELETE /inbox/:id | { deleted: true } | Permanent — cannot be undone |
Preference routes
| Route | Returns | Notes |
|---|---|---|
GET /preferences | RecipientPreference[] | All preferences for the authenticated user |
POST /preferences | RecipientPreference | Upserts — creates if not exists |
{
"notificationId": "comment_mentioned",
"channels": { "email": false }
}Other routes
| Route | Auth | Returns | Notes |
|---|---|---|---|
GET /notifications | Optional | Notification metadata array | Public by default. Set protectNotifications: true to require auth. |
GET /deliveries | Admin | DeliveryRecord[] | Sensitive fields (body, subject, to) are redacted. Non-admins see only own records. |
Unsubscribe, realtime & webhooks
| Route | Auth | Purpose |
|---|---|---|
GET /unsubscribe?token=... | HMAC token | Human click from email — verifies token, disables email, renders confirmation |
POST /unsubscribe | HMAC token | RFC 8058 one-click (mail client header). Same verification, returns 200. |
GET /inbox/stream | Required | SSE stream — inbox mutations in real time. React client connects automatically. |
POST /webhooks/:provider | Signature | Inbound 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" }| Status | error value | When |
|---|---|---|
400 | bad_request | Missing or malformed request body / query params |
401 | unauthorized | identify() returned null — no valid session |
403 | forbidden | Authenticated but not authorized (cross-tenant access, missing permission) |
404 | not_found | Resource doesn't exist or isn't owned by this user |
429 | rate_limited | requestRateLimit exceeded — includes Retry-After header |
500 | internal_error | Unhandled error — check server logs |
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
}
]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
| Option | Effect |
|---|---|
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. |
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:
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)
}| Platform | Auth approach | SSE support |
|---|---|---|
| Web (same origin) | Cookies via credentials: "include" | Native EventSource |
| Web (cross-origin) | Bearer token + CORS config on handler | Native EventSource (limited header support — use query param token) |
| React Native / mobile | Bearer token in Authorization header | Use react-native-sse or polyfill |
| Backend service | Service token or API key via custom identify() | Not needed — poll /inbox or use hooks server-side |
/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 type | Payload | When it fires |
|---|---|---|
inbox.created | Full InboxItem | New notification delivered to this recipient's inbox |
inbox.updated | Full updated InboxItem | Item 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 |
heartbeat | SSE comment (: heartbeat) | Every 30 seconds — keeps the connection alive past proxies |
Handling events in a custom client
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:
Set readAt / remove from list before the server responds.
POST to the server in the background.
Replace the optimistic state with the server's response (authoritative timestamps).
Revert to the previous state. Show a toast or error indicator.
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()
}
},
}
}| Operation | Optimistic behavior | Rollback on failure |
|---|---|---|
| Mark read | Set readAt instantly, decrement badge | Clear readAt, restore badge count |
| Mark all read | Set readAt on all items, zero the badge | Restore original readAt values and count |
| Archive | Remove from visible list immediately | Re-insert at original position |
| Delete | Remove from list, update count | Re-insert item, restore count |
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.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.
| Param | Type | Default | Purpose |
|---|---|---|---|
limit | positive integer | Adapter default | Maximum newest items to return |
archived | boolean | false | Include only archived items when true |
GET /api/notifykit/inbox?limit=20
GET /api/notifykit/inbox?archived=true&limit=20Troubleshooting
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| CORS error in browser console | cors not configured, or origin doesn't match | Set cors: "http://localhost:3000" (exact origin — no trailing slash, no wildcard in prod) |
| All requests return 401 | identify() returning null — session not resolving | Log inside identify(). Common causes: cookie not sent (credentials: "include" missing), auth middleware not running on this route. |
| 404 on all handler routes | Catch-all route not matching, or wrong path prefix | Verify file is at app/api/notifykit/[...notifykit]/route.ts. The segment name must match the SDK's base path. |
| Inbox returns empty but sends succeed | Handler 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 delivery | Preference 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 instantly | Response buffering by proxy, CDN, or middleware | Disable 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 production | Load balancer idle timeout shorter than heartbeat interval | The handler sends a heartbeat every 30 seconds. Set the load balancer idle timeout above that interval; see Realtime. |
| Unsubscribe link returns 401 | unsubscribeSecret not set on the handler, or env var is empty/missing in this environment | Verify process.env.NOTIFYKIT_SECRET is set. The unsubscribe route uses HMAC verification, not session auth — it needs the secret. |
| Mark-read returns 403 | Inbox item belongs to a different (recipient, tenant) pair than what identify() resolved | The handler enforces ownership. Check that the user's session matches the tenant context they're operating in. |
| Request works in Postman but not the browser | Cookies not sent (missing credentials: "include") or CORS blocking preflight | Add credentials: "include" to fetch calls. Ensure the handler's cors allows the browser's origin. |
Diagnostic checklist
Run through these checks in order when the handler isn't working. Each step eliminates one failure category:
Hit GET /api/notifykit/notifications with curl. If 404: the catch-all route file isn't in the right location.
Hit GET /api/notifykit/inbox with a valid session cookie. If 401: identify() isn't resolving your session.
If 200 but empty: send a test notification via notify.send() and try again. Ensure recipientId matches.
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 30sConnection lifecycle
Client opens GET /inbox/stream. Server runs identify() and holds the connection open.
Events push as mutations happen. Heartbeats fire every 30s to prevent proxy/load-balancer timeouts.
Network drops or tab closes. Browser EventSource auto-reconnects with Last-Event-ID.
Server replays missed events since the last ID. Client state catches up without a full refetch.
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.