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 | { "_ |
| 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_ | 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 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: 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:
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 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
tstring unchanged in the signed content. - Compare against
Date.now()directly (milliseconds), notDate.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
5xxfor 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 on a schedule you configure, regardless of the status code your handler returns. 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
200as 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.idcombined withtype, 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 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_SECRETper deployed environment, and never share the Production secret with non-production deployments. - Use the
x-knock-environment-idheader 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 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.
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.
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 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 for free and handle Knock webhooks reliably in minutes.