Handler routes
The REST routes the HTTP handler serves for the inbox, preferences, unsubscribe, and realtime.
Mounting
createHandler(notify, options) returns one function from a web Request to a Response that serves every route below. It does its own routing, so mount it once on a catch-all path. The React hooks call these routes for you; this page is for debugging and for clients that are not React.
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)
return session ? { recipientId: session.userId } : null
},
})import { createHandler } from "@notifykitjs/core"
const handler = createHandler(notify, {
identify: async (request) => {
const user = await getUserFromSession(request)
return user ? { recipientId: user.id } : null
},
})
// Bun, Deno, Hono, Cloudflare Workers: anything with Request and Response
Bun.serve({ fetch: handler })Paths are relative to basePath, /api/notifykit by default. Anything outside it gets a 404. See Next.js for the full integration.
Routes
| Method | Path | Auth | Does |
|---|---|---|---|
| GET | /inbox | User | List inbox items |
| GET | /inbox/unread-count | User | Count unread items |
| POST | /inbox/:id/read | User | Mark one read |
| POST | /inbox/mark-all-read | User | Mark all read |
| POST | /inbox/:id/archive | User | Archive one |
| POST | /inbox/:id/unarchive | User | Unarchive one |
| DELETE | /inbox/:id | User | Delete one |
| GET | /inbox/stream | User | Live inbox events (SSE) |
| GET | /preferences | User | List per-notification preferences |
| POST | /preferences | User | Update one notification's channels |
| GET, POST | /preferences/global | User | Read or update global channels |
| GET | /preferences/categories | User | List category preferences |
| GET, POST | /preferences/category | User | Read or update one category |
| GET | /preferences/explain | User | Explain how a notification's channels resolve |
| GET | /explain | User | Dry-run a send to the current user |
| GET | /notifications | Public | List the notifications you defined |
| GET | /deliveries | Permission | List delivery records |
| GET, POST | /unsubscribe | Signed token | Unsubscribe from a notification's emails |
| POST | /webhooks/:provider | Signature | Receive provider webhooks |
“User” means the request must pass identify(), and the route only ever touches that user’s data. A recipientId sent by the client is never used.
Request Flow
- A path outside
basePath, or a method and path with no route, returns 404. Withcorsset, anOPTIONSpreflight is answered here with 204. /notificationsanswers withoutidentify()unlessprotectNotificationsis on./unsubscribechecks its signed token instead, and/webhooks/:providerits signature.- Every other route calls
identify(request).null, an empty id, or an id over 512 characters returns 401. Ifidentify()throws, the response is 500. - With
requestRateLimitset, a user over the limit gets 429 and aRetry-Afterheader. - The route runs, scoped to the user’s recipient id, tenant, and workspace. Only
/deliveriesalso asksauthorize().
Identity and Permissions
identify() returns the recipient id as a string, or an object that can also carry the scope and permissions:
identify: async (request) => {
const session = await auth(request)
if (!session) return null
return {
recipientId: session.userId,
tenantId: session.orgId, // or organizationId
workspaceId: session.workspaceId, // optional
permissions: session.isAdmin ? ["admin"] : [],
}
}There are two permissions, deliveries.list and admin, and they only matter for GET /deliveries. Without an authorize option the handler checks the identity’s permissions array; with one, it calls authorize(context, permission) and uses its answer instead. Either way, the default is to refuse.
Options
| Option | Default | Effect |
|---|---|---|
identify | Required | Resolves the user for each request |
authorize | The permissions array | Decides deliveries.list and admin |
basePath | /api/notifykit | Where the routes live |
unsubscribeSecret | None | Must match createNotifyKit({ unsubscribe: { secret } }). Without it, /unsubscribe is a 404 |
protectNotifications | false | Require identify() for /notifications |
cors | Off | An origin, or "*". For a specific origin, credentials are allowed too |
requestRateLimit | Off | { max, windowMs } per user, in memory on each server |
webhooks | None | Signature verifiers by provider name. Enables /webhooks/:provider |
onWebhookEvent | None | (provider, payload), called for each verified webhook |
debug | false | Adds a fix hint to error responses. Leave it off in production: hints can name ids |
Responses
Successful JSON responses wrap their result in data. Errors have an error message and, for most failures, a code:
{ "data": { "count": 3 } }
{ "error": "Inbox item not found", "code": "NOT_FOUND" }| Status | code | When |
|---|---|---|
| 400 | none, or a NotifyKitError code | Bad JSON, a missing field or query parameter, an invalid limit, or a NotifyKitError such as PAYLOAD_VALIDATION_ERROR |
| 401 | UNAUTHENTICATED | identify() returned nothing |
| 401 | WEBHOOK_VERIFICATION_FAILED | A provider webhook's signature did not verify |
| 403 | FORBIDDEN | The item belongs to someone else, or a permission is missing |
| 403 | ORIGIN_MISMATCH | An unsubscribe POST came from another origin |
| 404 | none, or NOT_FOUND | No such route or item, or a feature that is not configured |
| 413 | none | A request body over 1 MB |
| 429 | RATE_LIMITED, SSE_LIMIT | Over requestRateLimit, or more than 10 open streams for one user |
| 500 | none | Your identify() or onWebhookEvent threw, or an unexpected error |
Inbox
| Route | data |
|---|---|
GET /inbox | InboxItem[], newest first, without archived items. ?archived=true returns only archived ones; ?limit= takes a positive integer, capped at 200 |
GET /inbox/unread-count | { count } |
POST /inbox/:id/read | The updated InboxItem |
POST /inbox/mark-all-read | { count }, the number marked |
POST /inbox/:id/archive | The updated InboxItem |
POST /inbox/:id/unarchive | The updated InboxItem |
DELETE /inbox/:id | { deleted: true }. Permanent |
An item that exists but belongs to another user returns 403; one that does not exist, 404.
Live updates
GET /inbox/stream is a server-sent event stream of the user’s inbox changes. It needs a realtime adapter and returns 404 without one. Each event is named after its type, so listen for the names rather than using onmessage:
const events = new EventSource("/api/notifykit/inbox/stream", { withCredentials: true })
events.addEventListener("inbox.created", (e) => {
const { item } = JSON.parse(e.data)
prepend(item)
})
events.addEventListener("inbox.refetch", () => reload())Event types are inbox.created, inbox.updated, inbox.archived, and inbox.unarchived (each with item), inbox.deleted (itemId), inbox.all_read (count), and inbox.refetch, sent when a slow client missed events and should reload. A comment line keeps the connection alive every 30 seconds, and the server closes each stream after 30 minutes; EventSource reconnects on its own. One user can hold 10 streams at once.
Preferences
| Route | Input | data |
|---|---|---|
GET /preferences | RecipientPreference[] | |
POST /preferences | { notificationId, channels } | The saved preference |
GET /preferences/global | The global preference, or null | |
POST /preferences/global | { channels } | The saved preference |
GET /preferences/categories | Every category preference | |
GET /preferences/category | ?category= | That category’s preference, or null |
POST /preferences/category | { category, channels } | The saved preference |
GET /preferences/explain | ?notificationId= | PreferenceExplanation |
POST bodies must be JSON with a Content-Type: application/json header. channels maps channel names (inbox, email, sms, webhook) to booleans; any other key or value is a 400.
curl -X POST https://app.com/api/notifykit/preferences \
-H "Cookie: session=..." -H "Content-Type: application/json" \
-d '{"notificationId":"comment_mentioned","channels":{"email":false}}'Explain, Notifications, Deliveries
GET /explain?notificationId=…dry-runs a send to the current user. Every other query parameter becomes a payload field, converted to a number or boolean where the schema says so. Returns aDeliveryExplanation. See Explain.GET /notificationsreturnsnotify.notificationMetadata: each notification’s id, channels, payload field types, and its description, category, and other settings, without templates. Use it to build a settings page.GET /deliveriesneedsdeliveries.listoradmin. It returns the user’s own delivery records, withto,subject, andbodyremoved. An admin can pass?recipientId=to see someone else’s, or leave it out to see everyone’s. Both stay inside the admin’s own tenant and workspace, when those are set. Takes?limit=, capped at 200.
Unsubscribe
The links NotifyKit puts in emails point here with a signed token. No login is needed; the signature is the proof.
GET /unsubscribe?token=…shows a small confirmation page with a button. Opening the link does not unsubscribe, so a mail scanner that follows links cannot do it by accident.POST /unsubscribeturns email off for that notification, for that recipient, and shows a confirmation page. The token can be in the query string, a form field, or a JSON body. A request with anOriginheader from another site is refused; one with noOrigin, as mail clients send for one-click unsubscribe (RFC 8058), is allowed.
A missing or tampered token gets a 400 page; one for a notification or recipient that no longer exists, a 404 page. See Preferences and unsubscribe.
Provider Webhooks
To receive delivery events from a provider (bounces, complaints, opens), register a verifier under its name. POST /webhooks/:provider then checks each request and hands the parsed body to onWebhookEvent:
createHandler(notify, {
identify,
webhooks: {
resend: (headers, rawBody) => verifyResendSignature(headers, rawBody, secret),
},
onWebhookEvent: async (provider, payload) => {
// record the bounce, update your own tables, alert
},
})An unknown provider name is a 404, a failed or throwing verifier a 401, and a verified request answers { ok: true }. NotifyKit does not change delivery records from these events yet; what you do with them is up to onWebhookEvent. See the Roadmap.