# Guide to Knock Webhooks: Features and Best Practices

Knock webhooks notify your systems as notifications move through their lifecycle: a message is sent, delivered, bounced, read, or clicked. They also fire when someone updates or commits a Knock resource such as a workflow, an email layout, or a translation. If you're syncing delivery status into your own database, suppressing bounced addresses, feeding engagement analytics, or reacting to workflow commits in CI, webhooks are how your systems find out without polling the Knock API.

This guide covers how Knock outbound webhooks work, how to set them up per environment, the HMAC-SHA256 verification scheme (and the millisecond timestamp that trips up Stripe-style verifiers), Knock's delivery behavior, and the best practices for production.

## What are Knock webhooks?

Knock outbound webhooks are HTTP callbacks that Knock sends to your application when events occur in a Knock environment. When a subscribed event fires, Knock POSTs a JSON payload to your endpoint. Every event shares a base shape: a `type` field naming the event, a `created_at` timestamp, a `data` object holding the entity the event is about (a Message object for `message.*` events, a workflow for `workflow.*` events, and so on), and an `event_data` object with extra context, which is `null` when there is none.

These are distinct from Knock's inbound sources, where your application sends events into Knock to trigger workflows. This guide covers the outbound direction only.

## Knock webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (Platform > Webhooks), scoped to the environment you create it in |
| Event selection | Choose the event types each webhook receives at creation time |
| Authentication | HMAC-SHA256 (base64) over `timestamp.body` in `x-knock-signature`, formatted `t=<timestamp>,s=<signature>` |
| Timestamp unit | Milliseconds since the Unix epoch |
| Signing secret | One per webhook, shown on the webhook's page in the dashboard |
| Envelope | `{ "__typename": "Event", "type": ..., "created_at": ..., "data": ..., "event_data": ... }` |
| Headers | `x-knock-event` (the event type), `x-knock-environment-id` (the webhook's environment), `x-knock-signature` |
| Retries | Retries non-2xx responses a handful of times over a few hours; no retry on `301`, `302`, `303`, `400`, `401`, `402`, `403`, `404`, or `405` |
| Rate limiting | Honors a `Retry-After` header on `429` responses where possible |
| Delivery logs | Status code, event type, timestamp, and full request payload for each attempt |
| Status | Toggle a webhook between enabled and disabled from its page |

## Common events

Knock event names are the `type` values:

| Event | Fires when |
| --- | --- |
| `message.sent` | A message is sent to a channel provider |
| `message.delivered` | The provider marks a message as delivered to the user |
| `message.delivery_attempted` | A delivery attempt fails and may be retried |
| `message.undelivered` | A delivery attempt fails permanently |
| `message.bounced` | A delivery attempt fails due to a bounce |
| `message.seen` | The recipient sees the message |
| `message.read` | The recipient reads the message |
| `message.archived` | The recipient archives the message |
| `message.interacted` | The recipient interacts with the message |
| `message.link_clicked` | The recipient clicks a tracked link |
| `workflow.committed` | A workflow is committed to the environment |
| `email_layout.committed` | An email layout is committed to the environment |
| `translation.committed` | A translation is committed to the environment |
| `partial.committed` | A partial is committed to the environment |

Each `.committed` resource event has a matching `.updated` event, and the read, seen, and archived states each have a reverting event (`message.unread`, `message.unseen`, `message.unarchived`). Branch on the `type` field at the top level of the body (or the `x-knock-event` header, which carries the same value); the entity lives under `data`, and event-specific details such as the clicked `url` or an undelivered message's `failure_reason` live under `event_data`.

> Inspect Knock webhooks as they arrive. Point Knock at a [Hookdeck Console](https://console.hookdeck.com) URL to inspect and replay real deliveries — no account or setup required.

## Setting up Knock webhooks

Knock webhooks are created in the dashboard. Open Webhooks in the sidebar under Platform, then click Create webhook. You'll be asked for the endpoint URL, an optional description, and the list of event types you want to receive. Pick only what your handler uses. Common starting sets are delivery monitoring (`message.sent`, `message.delivered`, `message.undelivered`, `message.bounced`), engagement (`message.read`, `message.link_clicked`, `message.interacted`), and resource changes for CI/CD (`workflow.committed`, `translation.committed`).

A webhook belongs to the environment you were in when you created it. To receive events from Development, Staging, and Production, create the webhook again in each one. Each webhook has its own signing secret, found on the webhook's page in the dashboard. It is not your Knock API key; store it as an environment variable such as `KNOCK_WEBHOOK_SECRET`, and give each deployed environment of your service the secret that matches its Knock environment.

From the same page you can toggle a webhook between enabled and disabled, or delete it from the three-dot menu (a deleted webhook has to be recreated to resume deliveries). The page also shows delivery logs with the status code, event type, timestamp, and full payload for every attempt, which is the first place to look when an integration goes quiet.

For local development, use the [Hookdeck CLI](/docs/cli): `hookdeck listen 3000 knock --path /webhooks/knock` gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Use the generated URL as the endpoint when you create the webhook in your Development environment, then trigger a workflow to generate real message events.

## Securing Knock webhooks

Knock signs each delivery with the webhook's secret and sends the result in a single header:

```
x-knock-signature: t=1715693400000,s=<base64_signature>

```

The `t` value is the time Knock generated the signature, in milliseconds since the Unix epoch. The `s` value is a base64-encoded HMAC-SHA256 of the timestamp and the raw request body joined with a period (`timestamp.body`).

Verification has four requirements. Use the raw request body, not parsed JSON; in Express that means `express.raw()` on the route. Keep the timestamp in milliseconds, both in the signed string and in the freshness check. Reject timestamps more than 5 minutes (300,000 milliseconds) from the current time, which is the window Knock's documentation suggests. Compare signatures with a timing-safe function. The Knock SDKs don't include an inbound verification helper, so use Node's `crypto` module:

```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();
const FIVE_MINUTES_MS = 5 * 60 * 1000;

function verifyKnockSignature(rawBody, header, secret, toleranceMs = FIVE_MINUTES_MS) {
  if (!header) {
    return { valid: false, error: 'Missing x-knock-signature header' };
  }

  // Header format: t=<timestamp_ms>,s=<base64_signature>
  const parts = header.split(',');
  const tPart = parts.find((p) => p.startsWith('t='));
  const sPart = parts.find((p) => p.startsWith('s='));
  const timestampMs = tPart ? tPart.slice(2) : null;
  const signature = sPart ? sPart.slice(2) : null;

  if (!timestampMs || !signature) {
    return { valid: false, error: 'Malformed x-knock-signature header' };
  }

  // Knock's timestamp is in MILLISECONDS, not seconds
  const ts = parseInt(timestampMs, 10);
  if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > toleranceMs) {
    return { valid: false, error: 'Timestamp outside tolerance' };
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestampMs}.${rawBody}`)
    .digest('base64');

  const a = Buffer.from(signature, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  if (a.length !== b.length) {
    return { valid: false, error: 'Invalid signature' };
  }

  return crypto.timingSafeEqual(a, b)
    ? { valid: true }
    : { valid: false, error: 'Invalid signature' };
}

app.post('/webhooks/knock', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const header = req.headers['x-knock-signature'];

  const verification = verifyKnockSignature(
    rawBody,
    header,
    process.env.KNOCK_WEBHOOK_SECRET
  );

  if (!verification.valid) {
    return res.status(400).send(`Webhook Error: ${verification.error}`);
  }

  const event = JSON.parse(rawBody);

  switch (event.type) {
    case 'message.delivered':
      // Mark the message as delivered in your database
      break;
    case 'message.bounced':
      // Suppress further sends to this address
      break;
    case 'message.link_clicked':
      // Record the click; the URL is in event.event_data.url
      break;
    case 'workflow.committed':
      // Trigger a CI job or cache refresh
      break;
  }

  res.status(200).json({ received: true });
});

```

Note the units and encoding: because Knock's `t` is milliseconds and the signature is base64, the code uses `.digest('base64')` and never divides by 1,000.

> Make Knock webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies deliveries at the edge, deduplicates, and durably queues every event with replay for anything that fails.

## Knock webhook limitations and pain points

### A Stripe-style header with a different timestamp unit

The Problem: The `x-knock-signature` header looks exactly like Stripe's `t=...,s=...` format, so the obvious move is to reuse a Stripe verifier. Every signature check then fails, and the error gives no hint why.

Why It Happens: Knock's timestamp is in milliseconds, where Stripe's is in seconds, and Knock's signature is base64 rather than hex. A ported verifier gets the freshness check wrong by a factor of 1,000 and computes the HMAC with the wrong encoding.

Workarounds:

* Write the Knock verifier from scratch rather than adapting another provider's, and keep the raw `t` string unchanged in the signed content.
* Compare against `Date.now()` directly (milliseconds), not `Date.now() / 1000`.
* When debugging, log the parsed timestamp and its delta from the current time: a delta in the trillions means a seconds-vs-milliseconds mix-up.

How Hookdeck Can Help: Hookdeck verifies the `x-knock-signature` HMAC at the edge with the correct millisecond timestamp and base64 encoding, so unverified traffic never reaches your handler, and every delivery is logged with full headers and body for debugging.

### Some failures are never retried

The Problem: Knock retries non-2xx responses, but not all of them. A `400`, `401`, `403`, `404`, or `405` (or a `301`, `302`, or `303` redirect) ends delivery for that event on the first attempt. A route that 404s during a deploy, an auth middleware that returns 401, or a moved endpoint that redirects will drop events without a second chance.

Why It Happens: Knock treats those status codes as permanent failures. For everything else, it retries a handful of times over a few hours, and the documentation says the exact number and intervals can change, so you can't plan around a fixed schedule either.

Workarounds:

* Return `5xx` for transient problems in your own code (a database outage, a downstream timeout) so Knock retries them.
* Keep the webhook route outside any auth or redirect middleware, and make sure HTTPS redirects don't apply to it.
* Check the webhook's delivery logs in the Knock dashboard after any deploy or URL change.

How Hookdeck Can Help: Hookdeck accepts every Knock delivery and durably queues it, then delivers to your endpoint with [automatic retries](/docs/retries) on a schedule you configure, regardless of the status code your handler returns. [Issues](/docs/issues) alert you when deliveries fail, and any event can be replayed once the fix ships.

### Timeouts turn into duplicate deliveries

The Problem: If your handler does the work before responding, a slow database or downstream API can push the request past Knock's timeout. Knock then retries an event your handler may already have processed, so the same `message.delivered` or `message.bounced` arrives twice.

Why It Happens: Knock's documentation says to respond with a 2xx before operating on the data, because a timeout results in a retry. Knock's documented base payload shape also has no event-level ID field, so there's no single value in the envelope to dedupe on.

Workarounds:

* Respond with `200` as soon as the signature verifies, then process from a queue or background job.
* Build an idempotency key from stable fields, such as the message ID in `data.id` combined with `type`, and record processed keys.
* Write state changes as upserts ("set status to delivered") rather than increments, so a repeat is harmless.

How Hookdeck Can Help: Hookdeck responds to Knock immediately and queues the event, so slow processing in your service never causes a Knock-side retry. [Deduplication](/docs/deduplication) rules can drop repeated deliveries based on the full payload or on fields you choose before they reach your handler.

### One webhook per environment, each with its own secret

The Problem: A webhook only exists in the environment it was created in. Running Development, Staging, and Production means creating and maintaining three webhooks, each with its own event selection and signing secret, and keeping them in step by hand.

Why It Happens: Knock scopes webhooks, and their secrets, to an environment. Creating a webhook in one environment doesn't copy it to the others.

Workarounds:

* Keep a written checklist of event types per webhook so environments don't drift.
* Store one `KNOCK_WEBHOOK_SECRET` per deployed environment, and never share the Production secret with non-production deployments.
* Use the `x-knock-environment-id` header to confirm each delivery came from the environment you expect.

How Hookdeck Can Help: Point each Knock environment at its own Hookdeck source, then use [filters](/docs/filters) on `type` or the `x-knock-event` header to route events to the right destinations. Routing changes happen in Hookdeck configuration rather than in three separate Knock webhooks.

## Best practices

### Verify with the raw body, in milliseconds

Signature verification requires the raw request body, the millisecond timestamp exactly as sent, base64 encoding, and a timing-safe comparison. Parsed-then-reserialized JSON and a seconds-based freshness check are the two most common causes of verification failures.

### Acknowledge fast, process asynchronously

Knock's own guidance is to return a 2xx before operating on the data, because a timeout triggers a retry. Return `200` once the signature checks out and hand the event to a queue or background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Choose status codes deliberately

Knock won't retry `3xx` redirects or `400` through `405`. Use those only when you want Knock to give up on an event, such as an invalid signature, and return `5xx` for failures you expect to recover from. If your endpoint is rate limited, return `429` with a `Retry-After` header.

### Make handlers idempotent

Retries mean the same event can arrive more than once. Key processing on stable fields such as `data.id` plus `type`, and make state updates safe to repeat. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Keep environments separate

Create one webhook per Knock environment, subscribe each to only the events it needs, and give every deployment of your service the secret for its own environment. Test in Development by triggering workflows against the Hookdeck CLI URL before you create the Production webhook.

## Conclusion

Knock outbound webhooks cover the full notification lifecycle (sent, delivered, bounced, seen, read, clicked) along with update and commit events for workflows, layouts, translations, and partials, all in the same envelope. Verification is standard HMAC-SHA256 over `timestamp.body`, with two details that matter: the timestamp is in milliseconds and the signature is base64.

Knock's retry behavior is loosely specified and skips several 4xx and 3xx codes outright, so your endpoint should acknowledge fast, choose status codes deliberately, and process idempotently to keep notification state accurate. [Hookdeck Event Gateway](/event-gateway) puts verification, deduplication, and a durable queue with replay in front of your endpoint, so your handlers only ever process trustworthy events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Knock webhooks reliably in minutes.