How to Build a Notification System in Next.js with Knock

Communication is critical to any product or service and while a single email blast might be straightforward, what if you wanted you wanted more control over how or when that email was being sent? Like based on when someone interacts with part of your app? Or different messages based on who they are? That sounds like a lot of work, but it doesn’t have to be! We’ll see how we can easily set up an in-app and email notification system in Next.js with Knock.

Table of Contents

YouTube Preview
View on YouTube

Disclaimer: This post is sponsored by Knock. Learn more about sponsored content.

What is a notification system?

Notifications are messages in a variety of forms that ultimately deliver information to a user (or API!). That might be an email, SMS, Slack message, or an in-app notification feed that allows to you to easily check in on new messages right where you work.

The system represents how all of these different mediums come together including the rules that dictate what gets sent when and where as well as which medium the message is delivered with.

This can be complicated to set up and configure as there are a lot of moving parts, but that’s where Knock comes in to make it easy to coordinate.

What is Knock?

Knock is a notifications platform that allows you to create your own powerful notification system. It uses the concept of Workflows to allow you to custom tailor how your users get notified and when, to whatever granularity you choose.

Knock Workflow editor

This granularity can come from any data point, whether it’s about the user you’re messaging or the context they’re being sent a message from. It can be time-based or maybe even based on if someone else performance an interaction.

The point is, the granularity is what you make of it which gives you a lot of power and flexibility for how you’re able to design your notification system.

What are we going to build?

We’re going to set up a notification system for a Next.js app that communicates the status of invoices based on activity from users within the app.

The app centers around invoicing, where a user can create an invoice, update the status, and ultimately mark it as paid. Throughout the invoice’s lifecycle, we want to update the user through a notification system about the update, including in-app notifications and emails. We’ll also want to update the person who was invoiced once the invoice was paid.

This sounds like a lot of moving parts, and it is, but putting this all together is more straightforward than it sounds. The beauty of it is in how Knock allows you to trigger Workflows from different points of your application, allowing you to route when and how people get notified different based on the context of that Workflow run.

Let’s see how this works in practice.

Step 0: Setting Up a New Next.js app

For this tutorial, I’m going to be working out of an example invoicing app that I started working on built on Next.js with Clerk as an authentication provider and Neon as a database which stores the invoicing details.

If you want to follow along, you can clone the project and easily set up your own account to quickly bootstrap the project.

You can find the repository including a README with setup instructions here:

Once you’ve set it up, feel free to add a few invoices to start.

Otherwise, you should be able to follow along with a similar setup or really any other auth provider, as the concepts should be the same.

Step 1: Installing & Configuring the Knock SDK

Getting started with our notification system, we’ll be using Knock, which requires use to install and configure their SDK in our Next.js app.

To get an idea of how we’ll work through this, in order to use Knock, we need to create a Workflow to send notifications, which we’ll ultimately trigger in the application. To trigger those Workflows, we’ll need to configure the SDK, which is where we’ll start.

So first, let’s install the SDK:

npm install @knocklabs/node

We’ll then want to configure out Knock account with our environment.

First, make sure you’ve signed up for a free account over at

Then we’ll want to find our API Public Key and API Secret Key, which you can find under Developers, then API Keys in the sidebar of your Knock dashboard.

Finding Knock API keys.

Create a new .env.local or open your existing file and add the following variables copied from your dashboard:

KNOCK_API_SECRET="<Your API Secret Key>"

And with that, we’ll be ready to get started digging into creating our first Workflow!

Follow along with the commit!

Step 2: Creating an Email Notification Workflow

To create a new Workflow, we have a few different parts to cover.

  • Creating a Channel (or Integration, such as an email provider)
  • Setting up the actual Workflow
  • Configuring a simple email template for our notification

This step will primarily be working in the Knock UI, building and configuring our Workflow.

Creating a new Channel

Starting off with creating a Channel, I’ll be using Resend as my example, but there are a few other popular providers like AWS SES, Mailgun, and Sendgrid.

Navigate to the Integrations section in your dashboard sidebar and select Create Channel.

Creating a new Channel

In the popup, select the email provider you’d like to use, where if you’re selecting Resend, you’ll need to scroll to the very bottom of the list.

Selecting Resend as an email provider

Once ready, hit Next.

On the next screen, we’ll name our channel. Knock defaults to the name of the provider, which in our case should work well.

  • Name: Resend
  • Key: resend

If you’re using multiple accounts of the same provider or maybe you want to segment out messages using different feeds for different use cases, it might make sense to name them relative to that use case, such as if you had a Marketing channel and an Engineering channel.

Creating a channel name

Feel free to update those values as you see fit, but when you’re ready, click Create.

Here we’ll be dropped into our Channel configuration page, where we’ll be able to get the details about the channel, but also be able to configure our Resend account.

We’ll start in the Development environment for this tutorial, so under Environment configuration then Development, select Manage configuration.

Manage Channel configuration

Here we have some options for how we configure our environment, but for now, we’re mainly interested in configuring three things: our API Key, the From email address, and From name.

The API Key should come from your email provider. If you’re using Resend like I am, you can create a new key under the API Keys section in your dashboard.

The Email Address should correlate to a verified email that you have configured in Resend.

The Name can be whatever you’d like, it’s technically optional, but I recommend adding something that your users will either recognize or will be easy to figure out.

Configuring channel environment

You can feel free to check out some of the other configurations, but those are the primary ones you want configured.

Once ready, selected Update settings at the bottom.

We also want to configure our Production environment. The setup is the same as we just did for our Development environment only you would use your Production keys (or the same keys).

Tip: If you’re using the same keys, you can simply select Copy from…, then select Development to easily copy your Development configuration to Production.

Setting up a new Workflow

Workflows are the magic behind Knock. We can use them to route our users through different paths based on who they are or the context that they’re pushed into a Workflow from. It creates a powerful mechanism that allows you to be as granular as you’d like for who (or what) gets which notification.

To start, head to the Workflows section in the left sidebar of your Knock dashboard.

Select Create workflow on the top right.

Creating a new Workflow

Next you’ll be given an opportunity to add a Name and Key.

The name should represent something that explains what event occurred that triggered the workflow or possibly what the goal of the workflow is. If you’re creating a 1-to-1 relationship based on events, perhaps name it based on the event. If you’re creating a 1-to-Many relationship, where you have one Workflow that represents many events, it likely makes sense to name it based off of the goal of the Workflow.

For our example, we’re going to create a Workflow that occurs whenever an invoice is created, so let’s use the following:

  • Name: Invoice – Created
  • Key: invoice-created

Note: The Key is autogenerated based on the name. I cleaned mine up to remove the extra dashes (-) that get added to replace spaces. You can change the Key to whatever identifier makes sense for you or leave it as the autogenerated value.

Configuring a new Workflow

When you’re ready, click Create.

You’ll be dropped into your Workflow details page. At this point, your Workflow is already created! But it’s not doing anything yet.

What we’re going to want to do is configure the Steps of our workflow, which uses a handy visual editor to drag and drop the different Steps we want to take in our workflow.

It’s essentially going to be a flow chart, where we have our incoming Trigger which is an API call, which flows into a Step and we can even create Branches which flow into other Steps.

We’ll cover more of those use cases later, but for now, we just want to simply send an email whenever an invoice is created.

Select Edit Steps.

Editing Steps from Workflow details page

Where we’ll now see our visual Workflow builder where if we scroll down on the right side of the page, we should see our Channels including our email provider, where in my case I configured Resend.

Click or drag your email provider over to the workflow to add your first Step.

And as one last thing, we can edit the email template to whatever we want to say in our email.

Click on Edit template under Message template.

Editing a Knock email template

Then in the Editor, we can first update the Subject to something that makes sense, such as “A new invoice has been created!”.

For the body of the email, we can drag in a Markdown content block, where we can use liquid templating to provide a dynamically generated email.

The following is making some assumptions for what our data is going to look like when we later send it to Knock programmatically from our application, which will be a data object including a name, email, description, and value from the invoice created as well as the email recipients name.

Hey **{{ | split: " " | first }}**,

A new invoice was created.

- Name: {{ name }}
- Email: {{ email }}
- Description: {{ description }}
- Value: {{ value }}

Once ready, click Save changes and hit the back arrow on the top left of the editor (<).

The last thing we need to do is publish our Workflow.

There are two important components for how Workflows work: committing Workflow changes to the current environment and promoting them to Production.

When we commit changes, you can think of it like we’re committing changes to a Git repository. Workflows are represented in code, so the changes we visually make will be committed to our Workflow “repository” of sorts.

Once we’re ready with our staged changes, we somewhat “merge them into main” to promote them to Production.

First, while still on the Workflow editor page, click Commit to development.

Knock will show a “diff” of the changes you’re proposing to make to the Workflow.

Since this is the first time we’re creating a Workflow, we’re only making additions, so everything will be green, but go ahead and again click Commit to development on the confirmation modal.

Committing changes to Development

When you’re ready, you can then promote your changes to Production by navigating to the Commits section in your Knock dashboard.

There we’ll be able to see the committed changes that have not yet been published, and when we’re comfortable, can go ahead and promote to Production.

Promoting commits to Production

But now, we have our Workflow set up, it’s committed to Development, promoted to Production, and we’re ready to start triggering and testing it!

Note: There’s a Test mechanism inside the Workflow details page and editor, but because we don’t have any user’s in Knock yet, we won’t have the ability to send a test. We’ll come back to this later, but alternatively, you can add a new User in the Users section of Knock.

Step 3: Triggering a Knock Workflow from Server Actions

Workflows are able to be triggered in pretty much any way you want, where in our case, we want to trigger a Workflow whenever an invoice is created.

In the example app that we set up in Step 0, we’re using a Server Action to take a form submission and create a new entry in our database for an invoice. If you’re following along, you can find this action as createInvoice inside of src/app/actions.tsx.

No matter how you have your Server Action set up, we’re ultimately running server code inside once the form is submitted, meaning we can additionally use the Knock Node SDK in order to trigger our workflow.

So first, let’s import the Knock Node SDK at the top of where we’re maintaining our Server Action, such as src/app/actions.tsx:

import { Knock } from '@knocklabs/node';

We also need to configure a new instance of Knock with our API Key that we set up in Step 1:

const knock = new Knock(process.env.KNOCK_API_SECRET);

And now we’re ready to trigger our workflow.

When we trigger a workflow, there are two sets of data that we need to pass along: the recipients that we want to send a notification to and the contextual data that we want to use in our notification.

If we remember in Step 2, we configured our notification in the form of an email template to receive a recipient where we’re using their name, as well as separate variables for name, email, description, and value.

So in a case where we’re already grabbing our active user and our form fields, such as:

export async function createInvoice(formData: FormData) {
  // From Clerk or your auth provider

  const user = await currentUser();

  if (!user) throw new Error('User not found');

  // Grab data from formData

  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const description = formData.get('description') as string;
  const value = formData.get('value') as string;

  // Store in your database

We would use this data to trigger a Knock Workflow:

await knock.workflows.trigger('<Your Workflow ID>', {
  data: {
  recipients: [
      name: user?.firstName || '',
      email: user?.emailAddresses.find(email => === user.primaryEmailAddressId)?.emailAddress,

In the above, we’re using the trigger method, along with a Workflow Key, the trigger payload (contextual data) we want to send, and our recipient, which is the user account that is associated with the created invoice. It’s important that for our recipient, that we’re sending at least an ID and an email, where the ID usually should be the same ID that your authentication provider assigned in their user object, unless you want to maintain it separately.

Note: Your data may look slightly different if you’re working with a different use case or a different auth provider. The important part is that you’re able to map the data that you’re using in your notification and the recipient you’re sending to.

The Workflow Key is important, where if you followed along with my example, you would use the value invoice-created, otherwise, you can find your Workflow Key in your Workflow details page.

Workflow Key on details page

Where now, if you submit your form, you should see a new email pop up in your Recipient’s email’s inbox!

Gmail showing email from Knock

But that’s not all…

If we head back into our Knock dashboard and navigate to Users in the left sidebar, we should now see our recipient listed!

Knock users with recipient listed

When we triggered our Knock workflow, we used “inline identification” to sync our recipient’s user data to Knock, as opposed to making a direct call outside of the context of an event to Knock to manually sync that data.

It probably makes sense to always inline identify users if you have the data already available, as you can make sure that it’s always up to date.

Alternatively, it can also be helpful to sync user data outside of the context of an event. Perhaps you wanted to programmatically seed Knock with all of your users from your authentication provider when you’re just getting started or maybe you want to regularly sync them on change to make sure the data is always fresh in Knock, such as if someone changes their email address.

This becomes more useful in cases such as if you only have an ID, but you need a variety of contact sources that you don’t have handy, including the email, but also other contact methods like a phone number.

For our use case, we’re primarily going to focus on event-based triggers where we’ll already easily have access to the data we need, so we’ll always identify inline.

But now that we have email notifications set up, let’s see how we can additionally get those notifications to show up inside a notification feed right inside of our app!

Follow along with the commit!

Step 4: Setting Up an In-App Notification Feed

Part of our goal of setting up a notification isn’t to simply send a single email, but to be able to send notifications through multiple channels from within the same Workflow.

You’ve likely already encountered this sort of notification system in any large, modern app or website, such as any social media site, where you can configure what notifications you want to which devices or medium. If you want, you can get notification of a new DM for Twitter/X via email and as an in-app push notification, just in-app, or neither.

Well, you don’t have to be a huge tech company to support that kind of system, we’re able to easily configure that in Knock AND we’re able to easily drop in a component to our application that receives live updates to pop up notifications as they occur.

Adding in-app notifications to a Workflow

To start off, we need to add the in-app notification option to our Workflow.

Let’s head back into our Workflow details page for Invoice Created (or your active Workflow) and into the Steps editor.

This time, we want to find the In-app channel at the bottom of the list and either click or drag it below Resend (or your email provider).

Adding In-app notifications to Workflow

Similar to before, we want to customize our Message template, but this time, because we’re using in-app notifications, we likely want to be a little more concise, so let’s add a message body of:

New Invoice: {{ name }} for {{ value }}

Note: Feel free to tweak this to whatever message you’d like! I’m keeping it a bit short to avoid a huge amount of text in the Notification Feed we’re about to set up.

If we also notice at the bottom of our Workflow editor, we have the ability to configure an Action URL.

This allows us to dynamically add whatever URL we want the person to go to if they try to interact with the in-app notification.

Because we’re dealing with a dynamically generated invoice, it likely makes sense for us to send that value when triggering our workflow, so let’s first update our Workflow trigger to now include a URL:

await knock.workflows.trigger('invoice-created', {
  data: {
    url: `<Invoice Path>` // Ex: /invoices/${}

In the above I’m adding the url property to the data we’re sending along to our Knock Workflow. Be sure to update the value with whatever location you want to send your user to when the notification is clicked.

We then want to update the Action URL to:

{{ url }}

Once ready, click Save changes, then navigate out of the workflow and click Commit to development.

And now, let’s add our in-app feed!

Using the Knock React SDK to add a Notification Feed

To get started with adding our Notification Feed, we need to first install the Knock React SDK.

In your terminal run:

npm install @knocklabs/react

Next, we’re going to take advantage of some components and Context providers that allow us to easily tap into building a Notification Feed component.

There are a few pieces to this:

  • KnockProvider – Hooks into your Knock account
  • KnockFeedProvider – Hooks into a specific feed (or channel)
  • NotificationIconButton – Button UI with a little notification badge
  • NotificationFeedPopover – The actual feed of notifications

We’re going to put these all together into a single component that will allow us to drop this in wherever we want.

Now to do this, I’m going to use a tweaked version of the pre-built component that Knock providers inside of their documentation, which is updated to work as a client component in the Next.js App Router.

Create a new file called NotificationFeex.tsx inside of your components directory and add the following:

"use client";

import { useState, useRef } from "react";
import { useUser } from "@clerk/nextjs"; // Use Clerk for user session
import { KnockProvider, KnockFeedProvider, NotificationIconButton, NotificationFeedPopover } from "@knocklabs/react";

import "@knocklabs/react/dist/index.css";

const NotificationFeed = () => {
  const [isVisible, setIsVisible] = useState(false);
  const notifButtonRef = useRef(null);

  const { user } = useUser();

  if ( !user ) return null;

  return (
    <KnockProvider apiKey={String(process.env.NEXT_PUBLIC_KNOCK_API_KEY)} userId={}>
      <KnockFeedProvider feedId={String(process.env.NEXT_PUBLIC_KNOCK_FEED_ID)}>
            onClick={(e) => setIsVisible(!isVisible)}
            onClose={() => setIsVisible(false)}

export default NotificationFeed;

Here’s what the above is doing:

  • Creating a new component that hooks into the Knock API’s Context provider as well as our Knock Feed
  • We’re dropping in the 2 components we discussed
  • Passing through our user from the current session
  • And configuring visibility of our components with state

Here’s what’s different from the snippet in the Knock documentation:

  • Added "use client"
  • Using Clerk for the current user, you can swap this if you’re using something else
  • Updated the environment variables to Next.js convention

This also means we need to make sure we have all of our environment variables set up, where we already set up NEXT_PUBLIC_KNOCK_API_KEY in Step 1, but we still need to set up NEXT_PUBLIC_KNOCK_FEED_ID.

Your Feed ID is going to be your Channel ID, which you can find under Integrations in your Knock dashboard, then selecting In-App.

Once there, copy your Channel ID.

Finding your Channel ID

Then add it as a new environment variable:

NEXT_PUBLIC_KNOCK_FEED_ID="<Your Channel / Feed ID>"

Finally, let’s drop in our new component somewhere!

I’m going to stick mine in the website header, where if you’re following along, you can find it under src/components/Header.tsx, so inside Header.tsx import your new NotificationFeed component and add:

<NotificationFeed />

And if you head back to your app, you should now see a little notification bell!

Notification bell for feed

It shouldnt show any notifications yet, but if you click it, you should see an empty feed, but the good news, is we can actually test it now!

Testing notifications from the Knock dashboard

Previously, we couldn’t use the in-Knock testing capability because we didn’t have any users, but since we’ve already synced at least one user when we ourselves were logged into the app, we should now be able to trigger a test for that user without having to actually go through creating a new invoice (or whatever your action is).

Back inside of Knock under Workflows, find your Workflow where mine is called “Invoice – Created”, and once navigated inside, you should see a button next to Commit called Run a test.

If you click that, it will ask you some options. We really only care about adding a recipient, which we can select our user, and adding some sample data, where in our case we only need a name and an amount.

Filling out some test data for Workflow

And once ready, click Run test.

If we now head back to our app, we may have to wait a few seconds for the notification to arrive, but once it does, we should see a little numbered badge on our bell! (As well as the original email)

Notification bell with 1 message

And if we click that, we should see our notification that we set up!

Notification feed with new message

You can feel free to go through the standard action to test this, such as creating an invoice in my case.

All that’s left now to do is promote our Workflow changes to production in the Commits section of the Knock dashboard so that it publishes the latest changes!

Follow along with the commit!

Step 5: Branching Workflows Based on Custom data

Now it’s time to get a bit more advanced with our Workflows.

We’re going to create a brand new Workflow where in our example of an Invoices app, we’ll communicate to both the current user and the person who’s being billed that the status of an invoice was updated.

However, we’re only going to communicate that under certain conditions, such as only notifying the person being billed if the invoice was paid. We’ll also send different messages to the user based on that status.

Tip: If you’re not following along with the demo app, this can really be applied to any scenario where you have conditions on when you want to send different messages.

For the most part, we’ll be working inside of the Workflow editor here, but we’ll need to add another trigger into our application similar to the one we added on invoice creation.

So let’s dig in.

Creating a second Workflow for regular updates

To start, let’s spin up a new workflow, where if you want to follow along, these are the details I’m going to use:

  • Name: Invoice – Updated
  • Key: invoice-updated

Inside of the Workflow Editor, let’s set up some notifications similar to the last step, where we can add an In-app notification and an email notification.

Note: If you don’t remember how to do some of the following, feel free to refer back to Step 4!

For the In-app Step, we can use the following when setting up the message template:

{{ name }}'s invoice for {{ value }} was changed to {{ status }}

Then for the Email Step, we can use the following:

  • Subject: An invoice was updated
  • Body: Markdown block with the below…
Hey **{{ | split: " " | first }}**,

An update was made to the following invoice:

[{{ name }}'s invoice]({{ url }}) for {{ value }} was changed to {{ status }}

Go ahead and test those changes, save them when ready, commit to Development, and publish to Production.

Next, to trigger this Workflow, we’re going to jump to the application.

If you’re following along, we can find the updateStatus action inside of src/app/actions.tsx.

Here, the basics of our trigger will look pretty similar to the trigger for invoice creation.

With your user and invoice data, add the following:

const invoicePath = `/invoices/${results[0].id}`;

await knock.workflows.trigger('invoice-updated', {
  data: {
    url: invoicePath,
  recipients: [
      name: user?.firstName || '',
      email: user?.emailAddresses.find(email => === user.primaryEmailAddressId)?.emailAddress,

In the above, we’re triggering our invoice-updated Workflow that we just created. In my case, I’m sending my invoice data which is stored in a results array. And similar to the last step, I’m passing along the user data as the recipient.

Let’s give this a try! Perform whatever action your application is going to trigger the Workflow such as updating the Invoice.

And as expected, we should get our notifications.

Email notification with invoice update
In-app notification with invoice update

That works great, but what about the person being billed?

Sending notifications to multiple recipients

This is going to work a little bit differently than our standard user. The person being billed doesn’t have a user ID associated with them and they’re not currently being synced to Knock, nor can they really without an ID.

So what we’ll do is create an ID based on the invoice that was created so that we have a way to repeatedly notify them but also have a link to the invoice itself.

With that in mind, let’s add a 2nd recipient to our invoice-updated trigger:

recipients: [
    name: user?.firstName || '',
    email: user?.emailAddresses.find(email => === user.primaryEmailAddressId)?.emailAddress,
    id: `payee_${results[0].id}`,
    name: results[0].name,
    email: results[0].email,

We’re using a pattern of payee_${invoiceId} as the ID to allow us to always reference the person being billed for that particular invoice. Because there’s always only one payee, we’ll know that reference will always remain the same.

Tip: There’s different ways you can handle this. Perhaps instead of using Clerk user IDs as the Knock ID, you’re using IDs you’re storing in a database, where you could create this reference for the person being billed. But either way, you’ll want a way to be able to pass the same reference ID each time they’re being notified, where we would achieve that by using the pattern of payee_${invoiceId}.

Further, I’m also passing the name and email of the person being billed, which come from the invoice database entry.

Now this one will be a bit more challenging to test than some of the other Workflows. If you’re working in a development environment like our invoice app, you’d likely want to try to create a new invoice with another email account that you have access to, so you can see both notifications come in individually.

This is what I did in my case, where I created an invoice under a email address, where once triggered, it landed in my inbox!

Email being sent to invoice payee

So now we’re on track being able to send our notifications to multiple users, but I don’t necessarily want to send the same thing to both users, I want custom messaging. I want to be able to say “Your invoice” was updated for the person actually being billed.

Using Branches to customize Workflow notifications based on user data

To make this work, we’re going to use another handy Workflow feature called Branches which like it sounds, will allow us to create different branches based on a condition.

In the invoice-updated Workflow editor, find Branch in the right column, and either click or drag it right below the initial Trigger and before any notifications.

Creating a new Branch

We’ll see immediately that we need to start setting up our Branch, where the condition that we’re going to use is whether they’re a user or the person being billed.

We don’t have an explicit field that has this information, which we theoretically could add, but using the data we have, we can look for the payee string in the Knock user ID that we added previously when adding a 2nd recipient to our trigger (ex: payee_${invoiceId}).

Under Condition type, select Recipient and for the Recipient property add id.

For Operator, we can use “contains” and we’ll add payee as the Recipient property value.

I’m also going to add a name to both the “If” and the “Else” of the Branch where:

  • If: Payee
  • Else: User
Branch configuration

When ready, click Add condition.

Now importantly, we want to make sure that we move our existing Email and In-app Steps under the appropriate Branch, where because we set them up as if they were the user creating the invoice, let’s move them under User.

Only sending notifications to User

We can now optionally test this out by committing and publishing to Production, where if we try to trigger the notification, we should no longer get a notification to the person being invoiced, but only the user creating the invoice.

But now, we can create new Steps that will only get sent to the person being invoiced!

Because this referenced person will never be logged in, we can skip the In-app notifications, but we’ll still want to Email them, so click or drag an Email step into the Payee branch.

Tip: You have the ability to add a custom label to each of the Steps you add. Because we’re now starting to add multiple instances of the same Channel or Integration, you could add a label that will make it easier to follow and understand what’s being sent where in the Workflow.

For the Email message template, we can add the following:

  • Subject: Your invoice was updated!
  • Body: In a markdown block…
Hey **{{ | split: " " | first }}**,

Your invoice for {{ value }} was changed to {{ status }}.

With that added, let’s test, save, commit, and publish.

And when trying to trigger the Workflow again, we should see both notifications with a custom message based on the recipient!

Custom messages based on recipient

Now this is great, but let’s take this a step further.

I don’t want to send every single update to the person being billed, I only want to notify them if it was marked as Paid. So let’s create a branch for that.

Setting custom Workflow branches based on contextual data

For this one, we’ll use the invoice data that’s being sent, where in particular, we’ll use the status property.

Since we just walked through how to create a Branch, try to see if you can work through this one yourself. Here’s what we’ll want to do:

  • Add a Branch with a condition based on invoice status
  • If it’s paid, we want to send notifications to both the user and payee
  • If it’s not paid, we only want to send notifications to the primary user who created the invoice
  • Create custom messaging for each Step based on Branch and context

Tip: It’s okay to have a Branch with no Steps!

Here’s what my Workflow looks like.

Workflow with multiple branches

In my Workflow, the main split is going to be between if it’s Paid or not. After that, no matter which Branch, I still want to customize what messages I send based on whether it’s the User or the Payee. But once I’m in those flows, I can simply use the Email and In-app steps to send the exact notifications I want.

Tip: It’s okay to be a little repetitive with Branch conditions, like Payee vs User. The important thing is that your Workflow works and that it’s easy to understand and follow through all of the conditions. As your Workflow grows, this will be increasingly important!

But there’s only one thing left now, and that’s to save, test, commit, and publish your Workflow, so that when trying to trigger a particular condition, such as marking it as Paid, we can see our notifications based on that context.

Paid invoice emails

Follow along with the commit!

Step 6: Controlling Notification Flow with Batching

Another way that we can control how our notifications are sent is by using another feature called Batching.

Imagine you have a big team managing your app and each of them are trying to make their own updates to the same thing. Or maybe its just a single person hitting the wrong thing or frantically switching a value back and forth.

Whichever the case, these updates could potentially trigger the same Workflow many times. That would likely mean you’re going to get spammed with all of those updates.

This is where Batching comes in, where we can tell Knock to hold off on sending those notifications for a set amount of time (or based on a value of the trigger payload), and once it gets to that time, send them all at once.

To do this, back inside of our Workflow editor, click or drag the Batch block Inside of the Not Paid, User Branch before the In-app or Email Step.

This Branch in particular is going to run on every Invoice update, so we want to hold off on sending a new notification on every change.

Workflow with Batching

We can stick with the 5 minute interval or customize it to our liking.

However, one tricky thing here is that in our notifications, we may be dealing with more than one Invoice once we hit the Email or In-app notification. That will break our existing message templates that expect only one set of data.

Luckily, Knock allows us to tap into both an activity and activities where we can check whether we’re dealing with 1 or many using a total_activities variable and act accordingly.

So back inside of our Workflow Editor, find the appropriate Email Step (Not Paid, User in my example) and let’s add the following as the body inside of an HTML block:

Hey **{{ | split: " " | first }}**,

An update was made to the following invoice:

{% if total_activities > 1 %}

{% for activity in activities %}
  <a href="{{ activity.url}}">{{ }}'s invoice</a> for ${{ activity.value }} was changed to {{ activity.status }}
{% endfor %}

{% else %}

<a href="{{ url}}">{{ name }}'s invoice</a> for ${{ value }} was changed to {{ status }}

{% endif %}

Note: It’s important this is in an HTML block, not Markdown block, for this to format properly in the email.

Here we’re checking to see if we have more than one activity. If we do, we work through the activities available in the form of a list. Otherwise, we look for our singular data in activity and add that to the page.

Now to test this, all we need to do is trigger a bunch of updates. That’s as easy as changing the Invoice status a bunch of times to something other than “Paid” within those 5 minutes!

And when we get the email after 5 minutes, we can see that we get a list of the updates rather than one for each.

List of updates in an email

Tip: When testing, you could set the interval to 1 minute to get a quicker response time, but be sure to increase it again once done testing!

Now as far as Paid goes, you certainly can still update your invoice status to Paid, but if you remember in our Workflow, we still want to get that right away, so it’s just not testing the Batching feature.

But we can still test that the Paid workflow Branch works by marking an invoice as Paid and we should get it right away!

Paid invoice email

The nice thing about Batches though is you can really create some interesting Workflows by taking advantage of it’s “Window type” mechanism, such as if you wanted to create reminder emails about an outstanding invoice based on a dynamic due date.

By crafting Workflows, you can really tailor your notification system to your exact needs for communicating with your visitors or customers.

Step 7: Syncing users with webhooks (Clerk)

For the last piece of this walkthrough, we’ll step back from the Workflow itself and loop around to making sure that our user data is always up to date.

In our examples, we’ve been using “inline identification” which helps to make sure that we’re always using the most accurate data available when triggering Workflows to notify our users.

This has worked great as we’ve already seen! But we can’t guarantee that we’ll always have all of the data available for inline identification. Not only might we not have it, it’s also a bit less efficient to continuously push all of that data through to Knock between requesting it from the database and sending it across the wire, particularly as our user data grows and the different user details we’re storing expands.

So what can we do about that?

One method to push data updates based on events our users take is to hook into our authentication provider’s webhooks and use events such as creating an account or signing in to regularly update Knock with the latest details.

In my case, I’m using Clerk as my authentication provider, so the example here will be specific to Clerk, but the concept should apply to any sort of event or webhook that you can tap into for your own provider.

The way this works is Clerk will allow us to specify an API endpoint that whenever any events we opt into occur (sign in, user creation), Clerk will send that endpoint a payload container the details about the event.

In that endpoint, we’ll consume that data and then simply pass it along to Knock using their identify method.

Now to make this work, Clerk has a really nice tutorial for how you can use their webhooks, however, the code they provide for the webhook is a bit much, so we’re going to take a shortcut and use a package created by Brian Morrison, who works at Clerk, to make that process a bit simpler.

But still, we’ll need to do the following:

  • Set up a new endpoint in Clerk (we’ll need to know our route path ahead of time)
  • Configure a signing secret from Clerk into our endpoint

Note: You’ll also want to verify that you webhooks route is NOT protected by Clerk middleware, otherwise you’ll have authentication issues with those requests

To start, let’s head over to Clerk and create a new endpoint.

Head into Clerk, find Webhooks in the left sidebar of your project dashboard, and click Add Endpoint.

Adding a new webhook endpoint in Clerk

Here we’ll need to enter a webhook URL. This URL will be based on where you have your application deployed or generally available.

Tip: If you want to test locally, you can use a tool like ngrok to create a public endpoint. We’re not going to cover that here, but you can walk through that again over on Clerk’s webhooks tutorial.

Since we’re creating a new endpoint, we can use whatever route we choose, so for instance, I’ll use /api/webhooks/knock which will allow me to maintain all Knock operations.

So in your endpoint URL, you can add the full URL including the hostname such as:

https://<Your Domain>/api/webhooks/knock
New endpoint URL

We’ll also need to configure the events we want to subscribe to.

In our case, we’re interested in session.created, user.created, and user.updated, which will give us some good points of interaction with our users to capture any updated details or fill in the blanks that we may be missing.

Once selected, click Create.

Configuring and creating a webhook

Here, we’ll land on our webhook details page. This will be the hub for us to see logs and details about our webhook and even make changes in the future.

For right now though, we’re primarily interested in the secret that was generated for this webhook.

On the right hand column towards the bottom, find Signing Secret, view the secret, and copy the value.

Webhook signing secret

We’ll then want to paste this into our .env.local file as a new variable:

WEBHOOK_SECRET="<Your Signing Secret>"

Next, it’s time to create the webhook itself.

Create a new folder structure webhooks/knock inside of src/app/api (or inside the directory of your choice) and inside that, add a new file knock.ts.

This file is going to be where we receive the webhook payload and trigger our update to Knock.

Like I mentioned before, the Clerk tutorial provides a huge code snippet to support a webhook, but we’ll instead use a package to simplify this, so let’s first install that package:

npm install @brianmmdev/clerk-webhooks-handler

Then inside src/app/api/webhooks/knock/knock.ts add:

import { UserJSON, SessionJSON } from "@clerk/nextjs/server";
import { createWebhooksHandler } from "@brianmmdev/clerk-webhooks-handler";

const handler = createWebhooksHandler({
  onUserCreated: async (payload: UserJSON) => {
    // Handle the payload...
  onUserUpdated: async (payload: UserJSON) => {
    // Handle the payload...
  onSessionCreated: async (payload: SessionJSON) => {
    // Handle the payload...

export const POST = handler.POST

This will be the basis of how we manage our webhooks, where we can see the different events and where we’ll receive the payload that we can send to Knock.

Starting with user creation, we ultimately want to send two things (at a minimum) from Clerk: name and email.

So first, let’s import the Knock package:

import { Knock } from '@knocklabs/node';

Create a new instance of Knock at the top of the file:

const knock = new Knock(process.env.KNOCK_API_SECRET);

Then we can trigger the identify method to send our payload data:

onUserCreated: async (payload: UserJSON) => {
  await knock.users.identify(, {
    name: payload.first_name || '',
    email: payload.email_addresses.find(email => === payload.primary_email_address_id)?.email_address,

For the most part, we’re taking the data as is and passing it along, but the only tricky thing here is that we need to look up the “primary” email in the list of emails by ID to make sure we use the right one for the account.

If we save this, we can even test this to make sure it’s running (or at least not throwing an error). For instance, you can add a console log inside to make sure it’s receiving the paylod as expected:


Then inside Clerk, head to the Testing tab, select the appropriate event, then scroll down and click Send Example.

Testing webhooks

If you check out your terminal, you should now see the payload show up!

Sending test payloads to webhooks.

Now, as far as the user update event, we can copy and paste the same exact code:

onUserUpdated: async (payload: UserJSON) => {
  await knock.users.identify(, {
    name: payload.first_name || '',
    email: payload.email_addresses.find(email => === payload.primary_email_address_id)?.email_address,

But when we get to session created, we need to do a bit more work, where the payload related to session created only returns a user ID, not the full user object, so in addition to the Knock identify call, we’ll also need to do a Clerk lookup.

First, we’ll need to add clerkClient to our Clerk import at the top:

import { UserJSON, SessionJSON, clerkClient } from "@clerk/nextjs/server";

Then, add the following to onSessionCreated:

onSessionCreated: async (payload: SessionJSON) => {
  const user = await clerkClient.users.getUser(payload.user_id);
  await knock.users.identify(payload.user_id, {
    name: user.firstName || '',
    email: user.emailAddresses.find(email => === user.primaryEmailAddressId)?.emailAddress,

As you can see, we’re using the getUser method to get the user by ID, where then we have enough information to identify our user.

Now the tricky thing with this is we can’t exactly test this like we could the create user because the test data doesn’t include any users that actually exist in our Clerk User directory. You can still console log out the payload, where you’ll see the user_id, you just won’t be able to verify the user information until it’s running with real values.

But what you CAN do, is if you’re testing locally with something like ngrok like we mentioned before, you can simply sign out and sign back in to your locally running project, which should create a new session, and log out your details!

Session webhook logging data

Now as far as this syncing to Knock, so far you might have just been logging in and our with the same user, but the way you can test that Knock is correctly getting updated information is by updating your Clerk user (or your authentication provider) or by signing up with a new user before creating any sort of invoice.

As soon as you create that account, Clerk should kick off the user created webhook, hit your webhook endpoint, and update Knock with your new user’s name and email!

Newly synced

Follow along with the commit!

What else can we do?

Create real users accounts for payees

In our example, we created a payee in Knock only to capture the user details. We basically used the ID of the primary user with a prefix of payee_ to capture that, but if what if we have a recurring user or perhaps if other people invoice that person?

We can dynamically generate an account for our users who are billed, which allows us to sync those users to our the invoice in a more reliable way, but it also gives a mechanism to allow those who are billed to potentially log in and see all of the invoices that they owe if the app supports it (or whateve the use case is).

Send reminders for upcoming invoices

We’re only sending notifications to users who are billed when the status changes to Paid. Instead, we should send reminder notifications to give them an opportunity to pay their invoice before the due date.

Better yet, we can use the invoice status as a dynamic value so that we can not send the reminder email if it’s already been paid.