How to Secure and Verify Notion Webhooks with Hookdeck
Notion has become the go-to workspace for teams managing everything from product roadmaps and wikis to project databases and knowledge bases. Notion's integration webhooks deliver real-time event notifications whenever pages, databases, or comments change in a workspace, enabling integrations that keep external systems in sync.
However, accepting webhooks without proper verification leaves your application vulnerable to spoofed requests. In this guide, we'll show you how to secure and verify Notion webhooks manually and with Hookdeck, ensuring that every webhook your application processes genuinely originated from Notion. For a comprehensive overview of Notion webhook capabilities, see our guide to Notion webhooks.
Why webhook security matters
Webhooks are HTTP callbacks that notify your application when events occur. Without verification, an attacker could send fake webhook payloads to your endpoint, potentially triggering unauthorized actions like:
- Syncing fabricated page or database changes to external systems
- Triggering automations based on spoofed Notion events
- Corrupting data pipelines that depend on Notion webhook accuracy
- Creating false audit trails from fabricated activity
Notion addresses this by signing every webhook with an HMAC-SHA256 signature via the X-Notion-Signature header. By verifying this signature, you can be confident that webhooks are authentic and haven't been tampered with in transit.
How Notion webhook signatures work
Notion uses HMAC-SHA256 signature verification. Every webhook request includes an X-Notion-Signature header containing the signature in the format sha256=<hex_digest>.
The X-Notion-Signature header
The header format looks like this:
sha256=5d7370c827b66f5e0b8e2e9b6ab0dc4f1e8a92c3d7f6b5a4e3d2c1b0a9f8e7d6
The signature is computed by:
- Computing an HMAC-SHA256 hash of the raw request body using the
verification_tokenas the key - Hex-encoding the result
- Prepending
sha256=
The verification token
Notion uses a unique setup flow for webhooks. When you create a webhook subscription, Notion sends a one-time POST request to your endpoint containing a verification_token:
{
"verification_token": "your-unique-verification-token"
}
This token serves a dual purpose:
- During setup: It confirms your endpoint is reachable — you paste it back into the Notion settings to complete verification
- After setup: It becomes the permanent HMAC signing key for all subsequent event deliveries
Important: Store this token securely when you first receive it. It cannot be retrieved again — if you lose it, you must delete the subscription and create a new one.
Setting up Notion webhook verification
Step 1: Create a webhook subscription
- Go to notion.so/profile/integrations and select your integration
- Navigate to the Webhooks section
- Enter your Webhook URL (must be HTTPS and publicly accessible)
- Click Create
Step 2: Receive and store the verification token
Notion sends a POST request to your endpoint with the verification_token. Your endpoint must:
- Receive the POST request
- Extract the
verification_tokenfrom the JSON body - Store the token securely (environment variable or secrets manager)
- Return an HTTP 200 response
Step 3: Complete verification in Notion
- Go back to your integration's webhook settings in Notion
- Click Verify
- Paste the
verification_tokenyou received - The subscription becomes active
The webhook URL cannot be changed after verification. To use a different URL, you must delete the subscription and create a new one.
Verifying webhook signatures manually
Verification process overview
- Extract the
X-Notion-Signatureheader - Strip the
sha256=prefix to get the received signature - Compute HMAC-SHA256 over the raw request body using the stored
verification_tokenas the key - Hex-encode the result
- Compare using a constant-time function
Node.js verification example
const express = require("express");
const crypto = require("crypto");
const app = express();
const NOTION_VERIFICATION_TOKEN = process.env.NOTION_VERIFICATION_TOKEN;
function verifyNotionWebhook(rawBody, signatureHeader) {
if (!signatureHeader || !signatureHeader.startsWith("sha256=")) {
console.error("Missing or malformed X-Notion-Signature header");
return false;
}
// Strip the sha256= prefix
const receivedSignature = signatureHeader.slice(7);
// Compute HMAC-SHA256
const computedSignature = crypto
.createHmac("sha256", NOTION_VERIFICATION_TOKEN)
.update(rawBody, "utf8")
.digest("hex");
// Use timing-safe comparison
const receivedBuffer = Buffer.from(receivedSignature, "utf8");
const expectedBuffer = Buffer.from(computedSignature, "utf8");
if (receivedBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
app.post(
"/webhooks/notion",
express.raw({ type: "application/json" }),
(req, res) => {
const signatureHeader = req.headers["x-notion-signature"];
const rawBody = req.body.toString();
const payload = JSON.parse(rawBody);
// Handle initial verification token delivery
if (payload.verification_token && !payload.type) {
console.log("Verification token received:", payload.verification_token);
// Store this token securely
return res.status(200).json({ ok: true });
}
if (!verifyNotionWebhook(rawBody, signatureHeader)) {
return res.status(401).json({ error: "Invalid signature" });
}
// Process the verified webhook
switch (payload.type) {
case "page.created":
handlePageCreated(payload);
break;
case "page.content_updated":
handlePageUpdated(payload);
break;
case "database.row_created":
handleDatabaseRowCreated(payload);
break;
default:
console.log(`Received event: ${payload.type}`);
}
// Notion uses sparse payloads — follow up with the API for full content
res.status(200).json({ received: true });
}
);
app.listen(3000, () => {
console.log("Webhook server listening on port 3000");
});
Python verification example
import hmac
import hashlib
import os
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
NOTION_VERIFICATION_TOKEN = os.environ.get("NOTION_VERIFICATION_TOKEN")
def verify_notion_webhook(raw_body, signature_header):
"""Verify a Notion webhook signature."""
if not signature_header or not signature_header.startswith("sha256="):
return False
# Strip the sha256= prefix
received_signature = signature_header[7:]
# Compute HMAC-SHA256
computed_signature = hmac.new(
NOTION_VERIFICATION_TOKEN.encode("utf-8"),
raw_body,
hashlib.sha256
).hexdigest()
# Use timing-safe comparison
return hmac.compare_digest(computed_signature, received_signature)
@app.route("/webhooks/notion", methods=["POST"])
def handle_webhook():
raw_body = request.get_data()
payload = request.get_json()
# Handle initial verification token delivery
if "verification_token" in payload and "type" not in payload:
print(f"Verification token: {payload['verification_token']}")
return jsonify({"ok": True}), 200
signature_header = request.headers.get("X-Notion-Signature", "")
if not verify_notion_webhook(raw_body, signature_header):
abort(401, "Invalid signature")
print(f"Received verified {payload.get('type')} event")
# Notion sends sparse payloads — follow up with API for full content
return jsonify({"received": True}), 200
if __name__ == "__main__":
app.run(port=3000)
Critical security best practices
Use timing-safe comparisons
Never use standard equality operators (== or ===) to compare signatures. These can leak timing information that attackers exploit. Always use:
- Node.js:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - Go:
subtle.ConstantTimeCompare()
Store the verification token securely
The verification_token is delivered only once during subscription setup and cannot be retrieved later. Store it in environment variables or a secrets manager — never hardcode it or commit it to version control.
Preserve the raw request body
The HMAC signature is computed on the exact bytes Notion sends. Any parsing, reformatting, or middleware that modifies the body before verification will produce a different signature.
Follow up with the Notion API for full content
Notion uses sparse (thin) payloads — events contain only IDs and metadata, not the full page or database content. After verifying a webhook, use the Notion API to fetch the current state of the changed resource. This is a deliberate design choice by Notion.
Handle event aggregation
Notion may batch high-frequency events within short time windows. Design your processing to handle deduplicated or aggregated event deliveries gracefully.
Simplifying verification with Hookdeck
Manually implementing and maintaining webhook verification across multiple providers can be complex and error-prone. Hookdeck provides a webhook gateway that handles verification automatically, allowing you to focus on processing events.
What is Hookdeck?
Hookdeck provides an event gateway that sits between webhook providers (like Notion) and your application. It provides:
- Automatic signature verification
- Event queuing and retry logic
- Request logging and debugging tools
- Local development tunneling
Setting up Notion webhooks with Hookdeck
Step 1: Install the Hookdeck CLI
npm install hookdeck-cli -g
yarn global add hookdeck-cli
brew install hookdeck/hookdeck/hookdeck
-
scoop bucket add hookdeck https://github.com/hookdeck/scoop-hookdeck-cli.git -
scoop install hookdeck
-
Download the latest release's tar.gz file.
-
tar -xvf hookdeck_X.X.X_linux_x86_64.tar.gz -
./hookdeck
Step 2: Authenticate
hookdeck login
This opens your browser for authentication. If you don't have a Hookdeck account, you can create one during this step.
Step 3: Create a connection
hookdeck listen 3000 notion-source --path /webhooks/notion
This command:
- Creates a public URL for receiving webhooks
- Forwards events to
http://localhost:3000/webhooks/notion - Displays the Source URL to configure in Notion
Step 4: Configure Notion
- Copy the Hookdeck Source URL from the CLI output
- In your Notion integration settings, navigate to Webhooks
- Enter the Hookdeck Source URL as the Webhook URL
- Click Create
- Your local endpoint will receive the
verification_token— store it securely - Go back to Notion and paste the token to complete verification
Step 5: Configure source verification
- Open the Hookdeck Dashboard
- Navigate to Connections and select your source
- Under Advanced Source Configuration, enable Source Authentication
- Select HMAC as the authentication method
- Configure the HMAC settings:
- Algorithm: SHA-256
- Header: X-Notion-Signature
- Encoding: Hex
- Signature Prefix:
sha256= - Secret: Your Notion verification token
- Click Save
How Hookdeck verification works
When verification is enabled:
- Hookdeck receives the webhook from Notion
- Hookdeck validates the
X-Notion-Signatureagainst the payload using your verification token - Valid requests are forwarded to your endpoint with
x-hookdeck-verified: true - Invalid requests are rejected and logged as "Verification Failed"
This means your application can trust any request from Hookdeck without implementing its own Notion signature verification logic. You only need to implement Hookdeck's signature verification on your server.
Troubleshooting common issues
Signature mismatch
If signatures don't match, verify:
- Raw body usage: Ensure you're using the exact bytes received, not a parsed/stringified version
- Token accuracy: Confirm you're using the correct
verification_tokenfrom the initial setup - Prefix handling: Remember to strip the
sha256=prefix before comparing - Encoding: Notion uses hex encoding for signatures
Verification token lost
If you've lost the verification_token, you must delete the webhook subscription in Notion and create a new one. Notion does not provide a way to retrieve the token after the initial delivery.
Webhook URL cannot be changed
Notion locks the webhook URL after verification. If you need a different URL, delete the subscription and create a new one. Using Hookdeck avoids this issue — the Hookdeck Source URL is permanent, and you can change where Hookdeck forwards events at any time.
Events contain only IDs
This is by design — Notion uses sparse payloads. After receiving and verifying a webhook, call the Notion API to fetch the full content of the changed resource.
Conclusion
Securing Notion webhooks requires verifying the HMAC-SHA256 signature on every incoming request using the verification_token obtained during subscription setup. While you can implement this verification manually, Hookdeck simplifies the process by providing automatic signature verification, event management, and development tools — and solves the URL lock-in issue by giving you a permanent endpoint URL that can route to any destination.