CodeWorlds
Back to collections
Guide16 min readCodeWorlds Team

Knock, a notification layer above providers

Knock turns one event into email, SMS, push, and Slack delivery. Versions 1.34.0 and 0.13.1, two licenses in one family, and a worked pricing example.

Knock, a notification layer above providers

Knock is a layer between your code and delivery providers: one workflows.trigger call turns into an email, an SMS, a push, an in-app feed entry, or a Slack message, depending on the recipient's preferences. The server package @knocklabs/node is at 1.34.0, published 16 July 2026, and the browser package @knocklabs/react is at 0.13.1, published 4 August 2026.

What Knock does beyond sending an email

Resend and Postmark accept an HTTP request and send one email. Knock sits one floor above and sends nothing itself. Channels are configured in the dashboard with your own provider credentials: for email the list covers Postmark, Resend, SendGrid, Amazon SES, Mailgun, Mailjet, Mandrill, MailerSend, SparkPost, and plain SMTP; for SMS it includes Twilio, Vonage, Plivo, Telnyx, Sinch, and MessageBird; for push APNS, Firebase, Expo, OneSignal, and Amazon SNS; for chat Slack, Microsoft Teams, Discord, and WhatsApp. Knock therefore does not replace the provider bill, it adds to it.

So what are you paying for. First, for deciding which channels a given event should reach a given recipient through, which means a preference set stored on Knock's side. Second, for steps an email provider does not have: collapsing many events into one message, throttling deliveries that come too often, and cancelling a queued delivery once it stopped being needed. Third, for the in-app channel: @knocklabs/client holds a websocket connection, and its dependencies list phoenix at 1.8.5, so the feed updates without polling.

Knock also accepts events from external sources rather than only from your code. The integration list includes Clerk, Stripe, PostHog, Segment, RudderStack, Hightouch, Census, and Supabase. For some teams that is the only reason to adopt Knock: notifications can be wired up without adding calls to the application.

The package family and the license split

The @knocklabs family carries two licenses, and that is not a misreading. Checking three sources gives a consistent but forked picture.

The server packages are Apache 2.0. @knocklabs/node 1.34.0 declares Apache-2.0 in the npm registry, the knocklabs/knock-node repository holds a LICENSE file with the full Apache License Version 2.0 text, and the published tarball contains package/LICENSE with the same text. The same holds for @knocklabs/mgmt 0.33.0 from 16 July 2026 and the Python package knockapi 1.29.0, uploaded to PyPI on the same day.

The browser packages and the CLI are MIT. @knocklabs/client 0.22.1, @knocklabs/react-core 0.15.1, @knocklabs/react 0.13.1, @knocklabs/react-native 0.11.1, and @knocklabs/expo 0.8.1 shipped on 4 August 2026, and @knocklabs/cli 1.2.3 on 13 August. All declare MIT in the registry, and the root of the knocklabs/javascript monorepo holds a LICENSE file with MIT text and the notice "Copyright (c) 2021 Knock Labs, Inc.”.

The third source, the package contents, shows a gap. @knocklabs/client ships a LICENSE file with MIT text. But @knocklabs/react, @knocklabs/react-core, @knocklabs/react-native, and @knocklabs/expo contain no license file at all, because the files field in their package.json is ["dist", "README.md"]. In the monorepo only packages/client/LICENSE exists; packages/react/LICENSE and the rest return 404. For those four packages the only license evidence is the npm metadata field plus the file at the repository root. A tool that scans package contents will find nothing there.

Is the split deliberate. It follows the repository boundary exactly: knock-node, knock-python, and knock-mgmt-node are Apache 2.0, while javascript and knock-cli are MIT. The pattern is too regular to be an accident, so I read it as a deliberate server versus client distinction, but Knock does not state this anywhere I could verify, and I label it accordingly: this is an inference, not a vendor statement.

Several things follow for an audit. A dependency license inventory will list two entries for one vendor, both permissive, so there is no conflict. Apache 2.0 does carry an explicit patent grant and a requirement to preserve notices, which MIT does not, so a rule saying "the whole X family is MIT" will be false. On top of that, @knocklabs/node carries src/internal/qs/LICENSE.md with a BSD 3-Clause license for a fork of the neoqs library, and that license is invisible in the npm license field.

Two smaller findings from the same pass. @knocklabs/cli 1.2.3 pins @knocklabs/mgmt at exactly 0.33.0, not a range, so an MIT package drags an Apache 2.0 package behind it. And the latest tag of @knocklabs/types points at the release candidate 0.1.5-rc-5.0 from 24 January 2025, while the stable 0.1.5 dates from 13 November 2024. @knocklabs/client asks for ^0.1.5, and that range excludes prereleases, so the stable build lands in your tree. Anyone installing @knocklabs/types by name gets the candidate.

Triggering a workflow from code

Installation and the minimal call look like this.

Code
Bash
npm install @knocklabs/node
npm install @knocklabs/react @knocklabs/client
npm install --global @knocklabs/cli

The server client has a single runtime dependency, jose in the ^6.0.11 range, used to sign user tokens.

Code
TypeScript
import Knock from '@knocklabs/node'

const knock = new Knock({
  apiKey: process.env.KNOCK_API_KEY,
  timeout: 30_000,
  maxRetries: 2
})

const { workflow_run_id } = await knock.workflows.trigger('new-comment', {
  recipients: ['user_123', 'user_456'],
  actor: 'user_789',
  cancellation_key: 'comment_5521',
  tenant: 'acme',
  data: {
    comment_id: '5521',
    document_title: 'Third quarter plan',
    body: 'Take a look at the risks section'
  },
  settings: { sandbox_mode: false, skip_delay: false }
})

await knock.workflows.cancel('new-comment', {
  cancellation_key: 'comment_5521',
  recipients: ['user_123']
})

The defaults are worth knowing, because they rarely make it into documentation examples. apiKey comes from the KNOCK_API_KEY variable, baseURL from KNOCK_BASE_URL with a fallback of https://api.knock.app, timeout is one minute, maxRetries is two, and logLevel is read from KNOCK_LOG and defaults to warn. Since timeouts are retried as well, the real wait can exceed the timeout value.

The limits are written into the package types. The recipients list holds at most one thousand entries per trigger. The data payload has a 10 MB ceiling, and any single string value longer than 1024 bytes is truncated in the logs. Runs are asynchronous, and the response carries only workflow_run_id. The cancellation_key field has to be supplied at trigger time, otherwise later cancellation is impossible, and it should be unique, because a key shared across several requests cancels them in bulk.

Recipient preferences

This is the part that is most expensive to rebuild yourself: a matrix of consents per channel, per category, and per individual workflow, along with an interface to edit it.

Code
TypeScript
await knock.users.setPreferences('user_123', 'default', {
  channel_types: {
    email: true,
    sms: false,
    push: true,
    in_app_feed: true,
    chat: false,
    http: true
  },
  categories: {
    marketing: false,
    'product-updates': { channel_types: { email: true, push: false } }
  },
  workflows: {
    'new-comment': { channel_types: { email: false, in_app_feed: true } }
  }
})

const set = await knock.users.getPreferences('user_123', 'default')
const all = await knock.users.listPreferences('user_123')
await knock.users.unsetPreferences('user_123', 'marketing-only')

There are six channel types: chat, email, http, in_app_feed, push, and sms. Each of the three levels accepts either a boolean or an object with its own channel_types, and resolution runs from the most specific, so a workflow-level setting beats a category, and a category beats a channel type. The second argument is the preference set identifier; default is the usual choice, but a recipient can hold several sets, which helps with separate sets per tenant. On the React side, @knocklabs/react-core exposes a usePreferences hook, so a settings screen does not need its own HTTP client.

Batching and throttling

The simplest example that shows why the extra layer exists. Ten people comment on the same document within a minute. Without a layer, ten events produce ten emails and the user turns notifications off. With a batch step, ten triggers collapse into one window, the template receives the whole activity list, and one email goes out saying there are ten new comments.

The step types available in a workflow are channel, batch, delay, http_fetch, branch, and throttle. The batch step has the settings batch_order, batch_window_type, and batch_window; the values the generator scaffolds are asc, sliding, and a thirty second window. A sliding window extends with every further event, so a run of comments arriving every twenty seconds keeps the batch open. The delay step takes delay_for with a unit and a value, http_fetch has method and url and pulls extra data mid-run, throttle limits how often a given recipient receives anything from this workflow, and branch splits the workflow conditionally.

Batching also changes the bill, because Knock counts messages sent. Ten triggers folded into one batch is one line item, not ten.

Now honestly about the other side. If you send password resets, order confirmations, and invoices, each on one channel and each immediately, then batching, throttling, and preferences have nothing to do. The email provider alone is enough, and Knock adds another service on the delivery path, another dashboard, and another bill. The break-even point is roughly the moment a second channel appears, or the first repeating event stream such as comments, mentions, or status changes.

Where the workflow logic lives

Workflows are edited in Knock's dashboard, so by default the decision about who receives what lives outside your repository. The CLI is a partial answer.

Code
Bash
knock login
knock init
knock workflow pull new-comment
knock pull --knock-dir ./knock
knock push --knock-dir ./knock --commit -m "batch window of five minutes"
knock commit --resource-type workflow --resource-id new-comment -m "template fix"
knock commit promote --to production
knock branch create feature-digest

The knock workflow pull command writes one directory per workflow containing workflow.json plus template files. Keys ending with the @ character are pointers to files on disk, so the email body stays a separate .html file that diffs sensibly in a pull request.

Code
JSON
{
  "name": "New comment",
  "key": "new-comment",
  "steps": [
    {
      "ref": "batch_1",
      "type": "batch",
      "settings": {
        "batch_order": "asc",
        "batch_window_type": "sliding",
        "batch_window": { "unit": "seconds", "value": 30 }
      }
    },
    {
      "ref": "email_1",
      "type": "channel",
      "channel_key": "postmark-transactional",
      "template": {
        "settings": { "layout_key": "default" },
        "subject": "New comments on {{ data.document_title }}",
        "html_body@": "email_1/html_body.html"
      }
    }
  ]
}

Beyond workflows the CLI handles layouts, template partials, translations, message types, in-app guides, audiences, and knock schema pull. Project configuration lives in knock.json, created by knock init, and knock workflow generate-types emits types for the data payload.

The limits matter before you write this into a release process. The --environment flags on knock push and knock commit have exactly one allowed value, development, so there is no direct push to production; you go through knock commit promote --to. On top of that Knock has its own branches, handled by knock branch create, switch, merge, rebase, list, delete, and exit, which is a second branching model you have to keep in step with the one in git by hand. The upshot is that workflows can be versioned and reviewed as JSON, but the full loop is pull, edit, push, commit, promote, not an ordinary deploy from your build server.

A comparison that frames the choice: in Inngest the workflow is a TypeScript function inside your repository and goes through ordinary code review. Knock gives you an editor and template previews for people outside the engineering team instead. That is a trade, not an advantage for either side.

Pricing and a worked example

The billing unit is a message, defined on the pricing page as a message successfully sent to a single user on a single channel. Recipients, workflows, channels, and team members are unlimited on every plan, so only traffic counts.

The Developer plan costs 0 USD and covers 10,000 messages, 500 guide active users, and 500 AI agent credits, after which the rate is 0.01 USD per credit. Logs are retained for 30 days and single sign-on works through Google only. The Starter plan is 250 USD per month for 50,000 messages, with every additional message at 0.005 USD, plus 2,500 guide active users at 0.05 USD each beyond that and 2,000 agent credits at 0.01 USD. Starter also removes Knock branding from the components. Enterprise is quoted individually, keeps logs for 90 days, and offers an alternative model billed by the number of unique users notified per month.

For the free plan no overage rate is published. The table only mentions upgrading, so until Knock publishes a price, treat the 10,000 limit as hard.

Let us work the example. Ten thousand users, five notifications each per month, on two channels on average. That is 50,000 notifications and 100,000 messages. Starter covers 50,000, the remaining 50,000 cost 50,000 times 0.005 USD, which is 250 USD. Together 250 plus 250, so 500 USD per month for Knock alone. The email half of that traffic, 50,000 messages, still has to go out through a provider: the Pro plan on Resend is 20 USD for 50,000 emails. About 520 USD in total, of which 500 USD is Knock.

The cheaper shape for comparison. The same users, three notifications per month, one channel, so 30,000 messages. That fits inside the Starter allowance, so 250 USD plus 20 USD for the email. Sending those same 30,000 emails directly through Resend costs 20 USD. The 250 USD per month difference is the price of preferences, batching, and the in-app feed, and that is the number to hold your own case against. With ten thousand users and one notification per month you land exactly on the free allowance of 10,000 messages, that is, on its edge.

Knock, email providers, and Novu

ToolLayerBilling unitFree planFirst paid tier
Knockorchestration above providersmessage to one user on one channel10,000 messages250 USD for 50,000 messages
Novuorchestration, self-hosting availableworkflow run10,000 runs, 20 workflowsfrom 30 USD for 30,000 runs
Resendemail provideremail sent3,000 emails, 100 per day cap20 USD for 50,000 emails
Postmarkemail provideremail sent100 emails per month15 USD starting at 10,000 emails

The difference in unit matters more than the difference in price. Knock counts messages, so a notification sent over three channels is three line items. Novu counts workflow runs, so the same notification on three channels is one line item, but its free plan caps workflows at twenty, environments at two, team members at three, and activity history at twenty four hours. Novu also has a self-hosted option: the root of the novuhq/novu repository holds a LICENSE file with MIT text. Knock offers no such option; the pricing page has no self-hosting tier, and the published repositories are client libraries, SDKs, and a CLI, not a server.

Resend and Postmark belong to a different category and do not compete with Knock, they work underneath it. Twilio is the same on the SMS side: it appears on Knock's list of SMS providers, so sending an SMS through Knock still ends up on a Twilio account, with Knock adding preference resolution and batching. If you are left with one channel and simple transactional notifications, the provider alone is the right choice.

On the interface side Knock ships ready components, which email providers do not have by definition.

Code
TypeScript
'use client'

import {
  KnockProvider,
  KnockFeedProvider,
  NotificationIconButton,
  NotificationFeedPopover
} from '@knocklabs/react'
import '@knocklabs/react/dist/index.css'
import { useRef, useState } from 'react'

export function NotificationBell({ userId, userToken }) {
  const [isOpen, setIsOpen] = useState(false)
  const buttonRef = useRef(null)

  return (
    <KnockProvider
      apiKey={process.env.NEXT_PUBLIC_KNOCK_PUBLIC_API_KEY}
      user={{ id: userId }}
      userToken={userToken}
      enabled={Boolean(userId && userToken)}
    >
      <KnockFeedProvider feedId={process.env.NEXT_PUBLIC_KNOCK_FEED_ID}>
        <NotificationIconButton
          ref={buttonRef}
          onClick={() => setIsOpen(!isOpen)}
        />
        <NotificationFeedPopover
          buttonRef={buttonRef}
          isVisible={isOpen}
          onClose={() => setIsOpen(false)}
        />
      </KnockFeedProvider>
    </KnockProvider>
  )
}

KnockProvider accepts, among others, apiKey, user, userToken, onUserTokenExpiring, timeBeforeExpirationInMs, host, i18n, logLevel, branch, and enabled, which defaults to true. The older userId property is marked deprecated in favour of user. The package's peer dependencies are React 17, 18, and 19 together with react-dom, while Next.js in the 13 to 16 range and @tanstack/react-router version 1 are marked optional in peerDependenciesMeta. Its ordinary dependencies include Knock's own design system, the @telegraph/* packages, in open ranges such as >=0.8.0, plus lucide-react at ^0.544.0. Open ranges on a component library mean a new release can enter your build with no change on your side, so a lockfile stops being a formality.

Common mistakes

Treating Knock as a delivery provider. Knock instructs, it does not send. An account at Postmark, Resend, or Twilio is still required and you pay two bills, which cost estimates tend to miss.

Budgeting in notifications rather than messages. One notification delivered by email, push, and feed is three line items on the invoice, not one.

Triggering without a cancellation_key. The key cannot be added later, and without it workflows.cancel has nothing to cancel, so the common "do not send the reminder if the user already came back" scenario stops being available.

Confusing settings.sandbox_mode with the channel setting. The field in the trigger overrides sandbox mode for all channels in that single run and does not change the channel's own configuration.

Editing in the dashboard without knock pull. Someone fixes a template in the browser, the repository falls behind, and rebuilding the environment restores the version from before the fix. If you decide to keep workflows in the repository, the direction has to be one way.

Installing without a lockfile while @telegraph/* dependencies sit in open ranges. And an entry saying "the whole @knocklabs family is MIT" in a license inventory will simply be untrue for the server packages.

FAQ

Does Knock replace Resend or Postmark?

No. Knock is a layer above providers and requires your own provider account wired into a channel configuration. Postmark and Resend both appear on its list of supported email providers, so there are two bills: messages at Knock and emails at the provider.

Can workflows be kept in the repository?

Partly. @knocklabs/cli pulls workflows with knock workflow pull into directories holding workflow.json and templates, and knock push sends them back. The limitation is that push and commit only operate in the development environment, and everything beyond that goes through knock commit promote --to.

Why do two Knock packages carry two different licenses?

Because they come from different repositories. @knocklabs/node from the knock-node repository is Apache 2.0, confirmed in the npm field, in the LICENSE file, and in the published tarball. @knocklabs/react from the javascript monorepo is MIT, except the tarball contains no license file, because its files field covers only dist and README.md.

What does serving ten thousand users cost?

At five notifications per user per month on two channels you reach 100,000 messages: 250 USD for the Starter plan plus 250 USD for the 50,000 messages above the allowance, so 500 USD, plus the email provider bill. At three notifications on a single channel you stay inside the Starter allowance and pay 250 USD.

When is Knock overkill?

When you have one channel, transactional notifications sent immediately, and no user-facing preferences. Batching, throttling, and preference resolution then have no application, while the layer adds cost, latency, and logic living outside the repository.

Sources: Knock pricing, integrations list, the javascript monorepo.

Read next

We use cookies to enhance your experience on the site