Knock Handles Notification Plumbing So I Don't Have To
I wired up Knock for a client's in-app notification center and stopped dreading the words 'can we add email alerts too?'
Every serious web app eventually needs notifications. Not the "we'll send one welcome email" kind — the real kind, where users need to know when something happened, across whatever channel they prefer, with preferences they can actually control. I've built that system from scratch three times and patched it twice. I won't do it again.
Knock is the first notification infrastructure service I've used that actually handles the full picture: in-app feeds, email, SMS, push, and Slack — all through one API, with a workflow engine sitting in the middle. After integrating it for a healthcare-adjacent client who needed both an in-app notification center and email digests with strict audit requirements, I have opinions.
The Problem It Actually Solves
The homegrown notification system always starts the same way. Someone says "just queue an email when this happens." So you fire a job, use your mailer, done. Then they want in-app notifications. Fine, you add a notifications table. Then per-user preferences — some people want email, some don't. Then they want a digest instead of one-at-a-time. Then push. Then "can we see which notifications a user has seen?"
By the time you're done, you've got 800 lines of half-tested infrastructure code that nobody wants to touch, no visibility into delivery failures, and a preferences UI that took two sprints to build and still has bugs.
Knock replaces all of that. You define workflows in their dashboard — drag-and-drop, or YAML if you prefer — and the workflow decides which channels fire, in what order, with what delay. User preferences are first-class. Delivery status is observable. The in-app feed has a real API with real-time support. It's the kind of thing I wish existed in 2018 when I was building a LIMS notification system for a biotech client and ended up with a rats' nest of Laravel jobs and a user_notification_preferences table with 14 boolean columns.
Getting Connected
Install the PHP SDK:
composer require knocklabs/knock-php
Then wrap it in a simple service class. I don't love injecting SDK clients directly all over the place:
<?php
namespace App\Services;
use Knock\KnockSdk\Client;
class KnockService
{
protected Client $client;
public function __construct()
{
$this->client = new Client(config('services.knock.secret_key'));
}
public function identifyUser(\App\Models\User $user): void
{
$this->client->users()->identify($user->id, [
'name' => $user->name,
'email' => $user->email,
'phone_number' => $user->phone ?? null,
]);
}
public function trigger(string $workflow, string $recipientId, array $data = []): void
{
$this->client->workflows()->trigger($workflow, [
'recipients' => [$recipientId],
'data' => $data,
]);
}
}
Knock uses its own user store. You identify users before (or at the point of) triggering — they need to exist in Knock's system for the in-app feed to work. I do the identify call in a RegisteredUser listener and on login if the user record is stale.
Triggering a Workflow
Here's the part that actually felt good to write. A new order comes in and I want to notify the assigned rep — in-app immediately, email if they haven't opened it in 15 minutes:
<?php
namespace App\Listeners;
use App\Events\OrderAssigned;
use App\Services\KnockService;
class NotifyRepOfAssignment
{
public function __construct(protected KnockService $knock) {}
public function handle(OrderAssigned $event): void
{
$order = $event->order;
$rep = $order->assignedRep;
$this->knock->trigger('order-assigned', $rep->id, [
'order_id' => $order->id,
'order_number' => $order->order_number,
'customer' => $order->customer->name,
'amount' => number_format($order->total / 100, 2),
'assigned_by' => $event->assignedBy->name,
]);
}
}
The workflow in Knock's dashboard handles the rest: send in-app, wait 15 minutes, check if the in-app was seen, if not send email. I didn't write a single line of delay logic or channel-routing logic in PHP. That boundary — data in, workflow handles delivery — is what makes this composable.
Rendering the In-App Feed
Knock provides a React component library for the notification bell + feed UI. If you're on a React frontend it drops in fast:
import {
KnockProvider,
KnockFeedProvider,
NotificationIconButton,
NotificationFeedPopover,
} from "@knocklabs/react";
import { useRef, useState } from "react";
export function NotificationBell({ userId, userToken }) {
const [isVisible, setIsVisible] = useState(false);
const notifButtonRef = useRef(null);
return (
<KnockProvider
apiKey={process.env.NEXT_PUBLIC_KNOCK_PUBLIC_KEY}
userId={userId}
userToken={userToken}
>
<KnockFeedProvider feedId={process.env.NEXT_PUBLIC_KNOCK_FEED_ID}>
<NotificationIconButton
ref={notifButtonRef}
onClick={() => setIsVisible(!isVisible)}
/>
<NotificationFeedPopover
buttonRef={notifButtonRef}
isVisible={isVisible}
onClose={() => setIsVisible(false)}
/>
</KnockFeedProvider>
</KnockProvider>
);
}
The userToken is a signed JWT you generate server-side. That's not optional in production — Knock requires it and will yell at you in the dashboard if you're running in development mode without it. Generate it like this:
public function knockToken(\App\Models\User $user): string
{
return $this->client->users()->generateToken($user->id);
}
Pass it down from your session or an authenticated endpoint. Straightforward.
The Gotchas That Will Bite You
User identification is not automatic. If you trigger a workflow for a user ID that doesn't exist in Knock yet, the in-app channel silently fails. Email may still go out via your provider (SendGrid, Postmark, etc.) depending on your configuration, but the feed won't have anything. I missed this during initial testing because I was seeding trigger calls before I'd wired the identify step. Build identification into your user creation flow, not as an afterthought.
Workflow versioning is real. When you publish a workflow change, it creates a new version. Triggers in flight on the old version complete on the old version. That's actually the right behavior — but it surprised me the first time I changed a template and then wondered why some users were still getting the old copy. Keep this in mind during rollouts.
The per-environment API key split matters. Knock gives you development and production environments with separate API keys and separate user stores. Don't let a staging seed script run against your production key. I added a guard in my service class:
public function __construct()
{
if (app()->environment('production') && str_starts_with(
config('services.knock.secret_key'), 'sk_test_'
)) {
throw new \RuntimeException('Knock test key used in production.');
}
$this->client = new Client(config('services.knock.secret_key'));
}
Paranoid? Maybe. But I've made that mistake with Stripe before and I'm not making it again.
Email template rendering lives in Knock, not your codebase. That's mostly a feature — your non-developer team can edit email copy without a deploy. But it means your templates aren't in version control by default. Knock has a CLI and a concept of "commit" for templates, and you should use it. Treat template changes like migrations: deliberate, tracked, reviewed.
Rate limits on the identify endpoint. I ran into this during a bulk backfill where I was identifying 40,000 existing users to bootstrap a new Knock environment. The bulk identify endpoint handles batches of 100, which isn't obvious from the main docs. Don't loop individual identify calls on a large user base — chunk it and use the bulk endpoint.
When I'd Reach for Knock
Knock earns its keep on any app where notifications are more than a side feature. If you have multiple channels, user preferences, or any kind of time-delayed or conditional logic ("only send this if they haven't logged in"), building it yourself will cost more than Knock's pricing before you account for maintenance.
I'd use it if:
- You need an in-app notification feed. Building that yourself with proper read/unread state, real-time updates, and a sane API is a multi-week project.
- You have more than two notification channels.
- Non-developers need to own notification copy.
- You're in a regulated space and need delivery audit trails without building them yourself.
I'd skip it if:
- Your entire notification surface is one transactional email type. Just use Postmark directly — no need for another abstraction.
- You're early-stage and notifications are truly a "later" problem. The free tier is generous (10k monthly notifications), so the cost isn't the issue — the learning curve and the architectural decision to externalize this are what I'd weigh.
- You need something on-prem or in your own VPC for compliance. Knock is SaaS. If your contracts say notification content can't leave your environment, Knock doesn't fit.
Pricing Reality
The free tier covers 10,000 notifications a month, which is enough to validate the integration and serve a small user base. Paid plans start around $100/month. For a client billing at least that much monthly, the cost of building and maintaining equivalent infrastructure in-house doesn't come close to paying off. I've started scoping it as infrastructure rather than asking "do we really need this" — same way I don't debate whether to self-host a mail server.
Knock is the first notification service I've used where I actually felt like I was building on solid ground rather than duct-taping abstractions together. The workflow engine is where it earns its money — the moment you stop writing channel-routing logic in PHP and start drawing it in a dashboard, a whole category of bugs stops existing. I still wish the PHP SDK docs were a little thicker, but the TypeScript SDK docs are thorough enough that I can usually infer what I need. For any app where notifications are a real product surface, it's the first thing I'm reaching for now.
Need help shipping something like this? Get in touch.