Skip to content
KnockDevelopers

Verify webhooks

Check that a webhook came from Knock by comparing the knock-webhook-token header with the secret token you set on the webhook.

Chat with a Developer

Your webhook URL is public, so anyone who finds it can send it a request. Before you act on a webhook, check that it came from Knock and reject anything that doesn't.

Knock does this with a secret token that you choose, the same way Telegram's Bot API does with secret_token. You set the token on a webhook. Knock then sends it, exactly as you set it, in the knock-webhook-token header of every delivery to that webhook, and your receiver compares the header with the value it expects. The token is the only proof Knock adds to a delivery, so check it on every request.

Each webhook has its own token, or none. Knock never returns the token with the webhook: hasSecretToken says whether one is set. Setting and reading a token needs an API key with the Admin role.

Set a token before you go live

Without a token on a webhook, deliveries to it have no knock-webhook-token header, and your endpoint can't tell Knock's requests from anyone else's.

Set a token

A token is 1 to 256 characters long and may contain only the letters A-Z and a-z, the digits 0-9, _ and -. Make it long and random, not a word or a phrase. For example, generate 64 hexadecimal characters:

Terminal
openssl rand -hex 32

Pass it as secretToken when you create the webhook:

Terminal
curl -X POST https://app.knock-ai.com/v1/webhooks \
  -H "x-api-key: $KNOCK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.yourcompany.com/webhooks/knock",
    "events": ["meeting.booked"],
    "secretToken": "fa0cb006958a09fd6a8b901706a54d39304e65914346d651bb860a09b5218c74"
  }'

To set or replace the token of a webhook you already have, send it with PATCH /v1/webhooks/{webhookId}:

Terminal
curl -X PATCH https://app.knock-ai.com/v1/webhooks/1c9e5b7a-3f2d-4e8a-b6c1-7d0f2a4e9b35 \
  -H "x-api-key: $KNOCK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"secretToken": "fa0cb006958a09fd6a8b901706a54d39304e65914346d651bb860a09b5218c74"}'

Knock answers with the webhook: 201 when you create it, 200 when you change it. hasSecretToken is true.

200 OK
{
  "id": "1c9e5b7a-3f2d-4e8a-b6c1-7d0f2a4e9b35",
  "name": null,
  "url": "https://hooks.yourcompany.com/webhooks/knock",
  "events": ["meeting.booked"],
  "status": "active",
  "hasSecretToken": true,
  "createdAt": "2026-11-02T09:30:00.000Z"
}

The next delivery attempt to that webhook carries the new token, including retries of earlier events. A value that breaks the rules above returns 400 with the code webhooks.invalid-secret-token, and Knock changes nothing: no webhook is created, and an existing one keeps its token. See Errors.

To read a webhook's token, call GET /v1/webhooks/{webhookId}/secret-token:

Terminal
curl https://app.knock-ai.com/v1/webhooks/1c9e5b7a-3f2d-4e8a-b6c1-7d0f2a4e9b35/secret-token \
  -H "x-api-key: $KNOCK_API_KEY"
200 OK
{
  "secretToken": "fa0cb006958a09fd6a8b901706a54d39304e65914346d651bb860a09b5218c74"
}

secretToken is null when the webhook has none. To remove the token, send {"secretToken": null} with PATCH /v1/webhooks/{webhookId}. Knock then stops sending the knock-webhook-token header to that webhook, so stop requiring it in your receiver first. Manage webhooks covers these routes and their errors.

The delivery headers

Every delivery is a POST with a JSON body and these headers:

HeaderSame on every attempt?Description
knock-webhook-tokenYes, unless you change the webhook's tokenThe webhook's secret token, exactly as you set it. Sent only when the webhook has one. This is the header you verify.
webhook-idYesThe event ID, the same as id in the body. Also the same for every webhook that receives this event. Deduplicate on it.
webhook-timestampNoWhen this attempt was sent, in Unix seconds. Each retry has a new timestamp.
knock-webhook-idYesThe ID of the webhook this delivery is for. A URL test has no webhook yet, so it sends 00000000-0000-0000-0000-000000000000.
content-typeYesAlways application/json.
user-agentYesKnock-Webhooks/1.
Delivery request
POST /webhooks/knock HTTP/1.1
host: hooks.yourcompany.com
content-type: application/json
user-agent: Knock-Webhooks/1
webhook-id: 5e2b9c4a-7d1f-4a3e-8b6c-0f9d2e1a3c7b
webhook-timestamp: 1793613600
knock-webhook-id: 1c9e5b7a-3f2d-4e8a-b6c1-7d0f2a4e9b35
knock-webhook-token: fa0cb006958a09fd6a8b901706a54d39304e65914346d651bb860a09b5218c74

{"id":"5e2b9c4a-7d1f-4a3e-8b6c-0f9d2e1a3c7b","type":"webhook.ping","apiVersion":"2026-11-01","occurredAt":"2026-11-02T10:00:00.000Z","data":{"webhookId":"1c9e5b7a-3f2d-4e8a-b6c1-7d0f2a4e9b35"}}

Header names are case-insensitive. This page writes them in lowercase. The body's fields are described in the webhook events reference.

Check the token

Read knock-webhook-token and compare it with the token of the webhook that delivers to this URL. Reject the request with 401 if the header is missing or the values differ.

Compare in constant time. An ordinary === or == stops at the first character that differs, and the response time can leak how much of a guess was right. The Node example hashes both values first: that gives two buffers of the same length, so crypto.timingSafeEqual takes the same time wherever they differ and never throws. Python's secrets.compare_digest is constant-time on its own.

The check reads only a header, so any JSON body parser is fine on this route.

Terminal
npm install express
server.mjs
import crypto from 'node:crypto';
import express from 'express';

// Set KNOCK_WEBHOOK_TOKEN_PREVIOUS only while you rotate the token.
const acceptedTokens = [process.env.KNOCK_WEBHOOK_TOKEN, process.env.KNOCK_WEBHOOK_TOKEN_PREVIOUS].filter(Boolean);
const app = express();

function sha256(value) {
  return crypto.createHash('sha256').update(value).digest();
}

function isValidKnockToken(received) {
  if (typeof received !== 'string') return false;
  const receivedDigest = sha256(received);
  return acceptedTokens.some((token) => crypto.timingSafeEqual(receivedDigest, sha256(token)));
}

app.post('/webhooks/knock', express.json(), (req, res) => {
  if (!isValidKnockToken(req.get('knock-webhook-token'))) {
    return res.sendStatus(401);
  }

  res.sendStatus(204);
  handleEvent(req.body);
});

function handleEvent(event) {
  console.log(`Received ${event.type} ${event.id}`);
}

app.listen(3000);

Both examples accept a second token from KNOCK_WEBHOOK_TOKEN_PREVIOUS so you can rotate the token without rejecting deliveries. Leave it unset the rest of the time.

No-code tools work too: any tool that can compare a request header with a stored value can check the token.

To try your check, ping the webhook. Before the webhook exists, send its URL and token to POST /v1/webhooks/test: Knock sends a webhook.ping to the URL with that token in knock-webhook-token, returns what your endpoint answered, and doesn't keep the token. Your account can test a URL once every 10 seconds.

Answer a failed check

Answer 401 when the check fails, and don't process the request. Knock retries 401 and 403, since they're common while you deploy or change the token, so a delivery your endpoint rejects by mistake isn't lost straight away. See Which failures are retried.

Log why a request failed (missing header or wrong token) and its webhook-id. Never log the token itself, received or expected.

If a ping or a URL test comes back with delivered: false and statusCode: 401, your endpoint was reached but rejected the request. Check that it compares against the token you set last on that webhook, or the one you sent with the test.

Rotate the token

Rotate a webhook's token when it may have been exposed, or on a regular schedule. You choose the new token, so your receiver can accept it before Knock starts sending it. Done in this order, no delivery is rejected.

Accept the old and the new token

Generate a new token. Deploy your receiver so it accepts both values. In the examples on this page, set KNOCK_WEBHOOK_TOKEN to the new token and KNOCK_WEBHOOK_TOKEN_PREVIOUS to the old one.

To check that your receiver accepts the new token before Knock sends it, send the webhook's URL and the new token to POST /v1/webhooks/test and confirm delivered: true.

Send the new token to Knock

Call PATCH /v1/webhooks/{webhookId} with {"secretToken": "<new token>"}, once for each webhook that uses the old token. It takes effect from the next delivery attempt to that webhook, retries included.

Stop accepting the old token

Ping each webhook you changed and confirm delivered: true. Then unset KNOCK_WEBHOOK_TOKEN_PREVIOUS and deploy again.

To stop using a token, remove the check from your receiver first, then send {"secretToken": null} with PATCH /v1/webhooks/{webhookId}.

What the token doesn't protect

A matching token proves that the sender knows your token. It proves nothing else, so know its limits:

  • No tamper protection. The token isn't tied to the request's content, so a request that carries it can carry any body.
  • No replay protection. A captured request still passes when it's sent again, minutes or months later. Deduplicating on webhook-id (see Handle duplicates) makes an exact replay of an event you already processed harmless.
  • It travels with every request. Proxies, load balancers, API gateways, request loggers and error trackers on your side can record request headers, and the token with them.

So treat the token like a password:

  • Use HTTPS end to end. Knock only delivers to HTTPS URLs on port 443 and checks the certificate, so the header is encrypted on the way. Don't terminate TLS somewhere that forwards the request over plain HTTP.
  • Keep it secret. Store it in a secrets manager or an environment variable, never in source code, and keep the header out of your logs.
  • Rotate it if it may have been exposed. See Rotate the token.

Next steps

Chat with a Developer

On this page