Knock is a developer-first notification platform — new Knock(apiKey) creates the SDK. knock.notify(workflowKey, { actor, recipients, data }) triggers a notification workflow. knock.users.identify(userId, { name, email, phone_number }) upserts a user. Workflows compose multi-channel steps: email, SMS, push, in-app, chat — all configured in the Knock dashboard with Liquid templates. knock.notify("new-comment", { actor: authorId, recipients: [mentionedId], data: { postUrl, preview } }) is a typical call. @knocklabs/react-notification-feed provides KnockProvider, KnockFeedProvider, NotificationFeed, and NotificationIconButton with badge count. knock.users.setPreferences(userId, { workflows: { "new-comment": { channel_types: { email: false, in_app: true } } } }) controls per-workflow, per-channel preferences. knock.notifyBatch(workflowKey, notifications[]) sends to many recipients. Claude Code generates Knock workflow triggers, in-app feeds, preference centers, and multi-tenant notification routing.
CLAUDE.md for Knock
## Knock Stack
- Version: @knocklabs/node >= 0.6, @knocklabs/react-notification-feed >= 0.3
- Init: const knock = new Knock(process.env.KNOCK_API_KEY!)
- Trigger: await knock.notify("workflow-key", { actor: userId, recipients: [recipientId], data: { key: "value" } })
- Identify: await knock.users.identify(userId, { name, email, phone_number })
- Batch: await knock.notify("workflow", { actor, recipients: [id1, id2, ...ids] })
- Feed: <KnockProvider apiKey={KNOCK_PUBLIC_KEY} userId={userId}><KnockFeedProvider><NotificationFeed /></KnockFeedProvider></KnockProvider>
- Preferences: await knock.users.setPreferences(userId, { workflows: { "key": { channel_types: { email: false } } } })
Knock Server Utilities
// lib/notifications/knock.ts — Knock notification utilities
import { Knock } from "@knocklabs/node"
export const knock = new Knock(process.env.KNOCK_API_KEY!)
// Workflow keys — match keys in Knock dashboard
const WORKFLOWS = {
welcome: "welcome",
passwordReset: "password-reset",
newComment: "new-comment",
newFollower: "new-follower",
orderConfirmed: "order-confirmed",
weeklyDigest: "weekly-digest",
teamInvite: "team-invite",
mentionAlert: "mention-alert",
billingAlert: "billing-alert",
} as const
type WorkflowKey = (typeof WORKFLOWS)[keyof typeof WORKFLOWS]
// ── User sync ──────────────────────────────────────────────────────────────
export async function syncKnockUser(user: {
id: string
email: string
name: string
phone?: string | null
avatarUrl?: string | null
plan?: string
}): Promise<void> {
await knock.users.identify(user.id, {
name: user.name,
email: user.email,
phone_number: user.phone ?? undefined,
avatar: user.avatarUrl ?? undefined,
// Custom properties accessible in workflow templates
plan: user.plan ?? "free",
})
}
// ── Notification triggers ──────────────────────────────────────────────────
export async function sendWelcomeNotification(userId: string, appName: string): Promise<void> {
await knock.notify(WORKFLOWS.welcome, {
actor: userId,
recipients: [userId],
data: {
appName,
loginUrl: `${process.env.APP_URL}/dashboard`,
},
})
}
export async function sendPasswordReset(userId: string, resetUrl: string): Promise<void> {
await knock.notify(WORKFLOWS.passwordReset, {
recipients: [userId],
data: {
resetUrl,
expiresIn: "60 minutes",
},
})
}
export async function sendNewCommentNotification(params: {
authorId: string
recipientIds: string[]
postTitle: string
postUrl: string
preview: string
}): Promise<void> {
if (params.recipientIds.length === 0) return
await knock.notify(WORKFLOWS.newComment, {
actor: params.authorId,
recipients: params.recipientIds,
data: {
postTitle: params.postTitle,
postUrl: params.postUrl,
preview: params.preview.slice(0, 200),
},
})
}
export async function sendMentionAlert(params: {
mentionedByUserId: string
mentionedUserId: string
contextUrl: string
preview: string
}): Promise<void> {
await knock.notify(WORKFLOWS.mentionAlert, {
actor: params.mentionedByUserId,
recipients: [params.mentionedUserId],
data: {
contextUrl: params.contextUrl,
preview: params.preview.slice(0, 200),
},
})
}
// ── Multi-tenant (SaaS) ────────────────────────────────────────────────────
export async function sendTeamInvite(params: {
invitedByUserId: string
recipientEmail: string
teamId: string
teamName: string
inviteUrl: string
}): Promise<void> {
// Recipient may not be a Knock user yet — use email object directly
await knock.notify(WORKFLOWS.teamInvite, {
actor: params.invitedByUserId,
recipients: [{ email: params.recipientEmail }],
tenant: params.teamId,
data: {
teamName: params.teamName,
inviteUrl: params.inviteUrl,
},
})
}
// Notify all members of a team (multi-tenant workspace)
export async function notifyTeamMembers(params: {
teamId: string
actorId: string
memberIds: string[]
workflowKey: WorkflowKey
data: Record<string, unknown>
}): Promise<void> {
const recipients = params.memberIds.filter(id => id !== params.actorId)
if (recipients.length === 0) return
await knock.notify(params.workflowKey, {
actor: params.actorId,
recipients,
tenant: params.teamId,
data: params.data,
})
}
// ── Bulk weekly digest ─────────────────────────────────────────────────────
export async function sendWeeklyDigest(userIds: string[]): Promise<void> {
// Knock handles batch delivery internally — single call for all recipients
await knock.notify(WORKFLOWS.weeklyDigest, {
recipients: userIds,
data: {
digestUrl: `${process.env.APP_URL}/digest`,
},
})
}
In-App Notification Feed
// components/notifications/KnockInbox.tsx — Knock in-app feed
"use client"
import {
KnockProvider,
KnockFeedProvider,
NotificationFeed,
NotificationIconButton,
useKnockFeed,
} from "@knocklabs/react-notification-feed"
import { useEffect, useRef, useState } from "react"
const KNOCK_PUBLIC_KEY = process.env.NEXT_PUBLIC_KNOCK_PUBLIC_KEY!
interface KnockInboxProps {
userId: string
userToken?: string // JWT signed with KNOCK_SIGNING_KEY for production
}
export function KnockInbox({ userId, userToken }: KnockInboxProps) {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
return (
<KnockProvider
apiKey={KNOCK_PUBLIC_KEY}
userId={userId}
userToken={userToken}
>
<KnockFeedProvider
feedId={process.env.NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID!}
// Real-time updates via WebSocket
defaultFeedOptions={{ archived: "exclude" }}
>
<div className="relative">
<button
ref={buttonRef}
onClick={() => setIsOpen(prev => !prev)}
className="relative p-2 rounded-full hover:bg-muted transition-colors"
>
<NotificationIconButton
ref={buttonRef}
onClick={() => setIsOpen(prev => !prev)}
/>
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 z-50 w-96 shadow-xl rounded-2xl border bg-popover overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h3 className="font-semibold text-sm">Notifications</h3>
<button
onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-foreground text-xs"
>
Close
</button>
</div>
<NotificationFeed
onNotificationClick={(item) => {
const url = item.data?.url as string | undefined
if (url) {
window.location.href = url
setIsOpen(false)
}
}}
renderItem={(props) => <CustomNotificationItem {...props} />}
/>
</div>
)}
</div>
</KnockFeedProvider>
</KnockProvider>
)
}
// Custom feed item rendering
function CustomNotificationItem({ item, onItemClick }: any) {
return (
<div
onClick={() => onItemClick(item)}
className={`flex gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors ${
!item.read_at ? "bg-blue-50/30 dark:bg-blue-950/20" : ""
}`}
>
{item.actors?.[0]?.avatar && (
<img
src={item.actors[0].avatar}
alt=""
className="size-9 rounded-full flex-shrink-0 object-cover"
/>
)}
<div className="flex-1 min-w-0">
<p
className="text-sm leading-snug line-clamp-2"
dangerouslySetInnerHTML={{ __html: item.blocks?.[0]?.rendered ?? item.data?.message ?? "" }}
/>
<p className="text-xs text-muted-foreground mt-0.5">
{new Date(item.inserted_at).toLocaleDateString()}
</p>
</div>
{!item.read_at && (
<div className="size-2 rounded-full bg-blue-500 flex-shrink-0 mt-1.5" />
)}
</div>
)
}
Preferences API Route
// app/api/notifications/preferences/route.ts — manage notification preferences
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@clerk/nextjs/server"
import { knock } from "@/lib/notifications/knock"
export async function GET() {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const preferences = await knock.users.getPreferences(userId)
return NextResponse.json({ preferences })
}
export async function PUT(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { channel_types, workflows } = await request.json()
await knock.users.setPreferences(userId, {
channel_types,
workflows,
})
return NextResponse.json({ ok: true })
}
For the Novu alternative when a fully open-source, self-hostable notification platform is required with the option to run on your own infrastructure and have no per-notification pricing — Novu stores workflows in your database while Knock is a fully managed service with a richer dashboard and better multi-tenancy for SaaS apps, see the Novu guide. For the Courier alternative when a visual notification studio with A/B testing notifications, analytics on delivery rates, and a large library of provider integrations (100+ channels) is preferred — Courier has a more visual workflow editor while Knock is code-first for developers, see the Courier guide. The Claude Skills 360 bundle includes Knock skill sets covering workflows, in-app feeds, and multi-tenant routing. Start with the free tier to try notification platform generation.