BackReference

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.

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)
		return session ? { recipientId: session.userId } : null
	},
})

Paths are relative to basePath, /api/notifykit by default. Anything outside it gets a 404. See Next.js for the full integration.

Routes

MethodPathAuthDoes
GET/inboxUserList inbox items
GET/inbox/unread-countUserCount unread items
POST/inbox/:id/readUserMark one read
POST/inbox/mark-all-readUserMark all read
POST/inbox/:id/archiveUserArchive one
POST/inbox/:id/unarchiveUserUnarchive one
DELETE/inbox/:idUserDelete one
GET/inbox/streamUserLive inbox events (SSE)
GET/preferencesUserList per-notification preferences
POST/preferencesUserUpdate one notification's channels
GET, POST/preferences/globalUserRead or update global channels
GET/preferences/categoriesUserList category preferences
GET, POST/preferences/categoryUserRead or update one category
GET/preferences/explainUserExplain how a notification's channels resolve
GET/explainUserDry-run a send to the current user
GET/notificationsPublicList the notifications you defined
GET/deliveriesPermissionList delivery records
GET, POST/unsubscribeSigned tokenUnsubscribe from a notification's emails
POST/webhooks/:providerSignatureReceive 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

  1. A path outside basePath, or a method and path with no route, returns 404. With cors set, an OPTIONS preflight is answered here with 204.
  2. /notifications answers without identify() unless protectNotifications is on. /unsubscribe checks its signed token instead, and /webhooks/:provider its signature.
  3. Every other route calls identify(request). null, an empty id, or an id over 512 characters returns 401. If identify() throws, the response is 500.
  4. With requestRateLimit set, a user over the limit gets 429 and a Retry-After header.
  5. The route runs, scoped to the user’s recipient id, tenant, and workspace. Only /deliveries also asks authorize().

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

OptionDefaultEffect
identifyRequiredResolves the user for each request
authorizeThe permissions arrayDecides deliveries.list and admin
basePath/api/notifykitWhere the routes live
unsubscribeSecretNoneMust match createNotifyKit({ unsubscribe: { secret } }). Without it, /unsubscribe is a 404
protectNotificationsfalseRequire identify() for /notifications
corsOffAn origin, or "*". For a specific origin, credentials are allowed too
requestRateLimitOff{ max, windowMs } per user, in memory on each server
webhooksNoneSignature verifiers by provider name. Enables /webhooks/:provider
onWebhookEventNone(provider, payload), called for each verified webhook
debugfalseAdds 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" }
StatuscodeWhen
400none, or a NotifyKitError codeBad JSON, a missing field or query parameter, an invalid limit, or a NotifyKitError such as PAYLOAD_VALIDATION_ERROR
401UNAUTHENTICATEDidentify() returned nothing
401WEBHOOK_VERIFICATION_FAILEDA provider webhook's signature did not verify
403FORBIDDENThe item belongs to someone else, or a permission is missing
403ORIGIN_MISMATCHAn unsubscribe POST came from another origin
404none, or NOT_FOUNDNo such route or item, or a feature that is not configured
413noneA request body over 1 MB
429RATE_LIMITED, SSE_LIMITOver requestRateLimit, or more than 10 open streams for one user
500noneYour identify() or onWebhookEvent threw, or an unexpected error

Inbox

Routedata
GET /inboxInboxItem[], 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/readThe updated InboxItem
POST /inbox/mark-all-read{ count }, the number marked
POST /inbox/:id/archiveThe updated InboxItem
POST /inbox/:id/unarchiveThe 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

RouteInputdata
GET /preferencesRecipientPreference[]
POST /preferences{ notificationId, channels }The saved preference
GET /preferences/globalThe global preference, or null
POST /preferences/global{ channels }The saved preference
GET /preferences/categoriesEvery 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 a DeliveryExplanation. See Explain.
  • GET /notifications returns notify.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 /deliveries needs deliveries.list or admin. It returns the user’s own delivery records, with to, subject, and body removed. 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 /unsubscribe turns 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 an Origin header from another site is refused; one with no Origin, 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.