Gareth Wilson Gareth Wilson

How to Secure and Verify Notion Webhooks with Hookdeck

Published


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:

  1. Computing an HMAC-SHA256 hash of the raw request body using the verification_token as the key
  2. Hex-encoding the result
  3. 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:

  1. During setup: It confirms your endpoint is reachable — you paste it back into the Notion settings to complete verification
  2. 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

  1. Go to notion.so/profile/integrations and select your integration
  2. Navigate to the Webhooks section
  3. Enter your Webhook URL (must be HTTPS and publicly accessible)
  4. 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:

  1. Receive the POST request
  2. Extract the verification_token from the JSON body
  3. Store the token securely (environment variable or secrets manager)
  4. Return an HTTP 200 response

Step 3: Complete verification in Notion

  1. Go back to your integration's webhook settings in Notion
  2. Click Verify
  3. Paste the verification_token you received
  4. 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

  1. Extract the X-Notion-Signature header
  2. Strip the sha256= prefix to get the received signature
  3. Compute HMAC-SHA256 over the raw request body using the stored verification_token as the key
  4. Hex-encode the result
  5. 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
    
    
  1.     scoop bucket add hookdeck https://github.com/hookdeck/scoop-hookdeck-cli.git
        
        
  2.   scoop install hookdeck
      
      
  1. Download the latest release's tar.gz file.

  2.     tar -xvf hookdeck_X.X.X_linux_x86_64.tar.gz
        
        
  3.   ./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

  1. Copy the Hookdeck Source URL from the CLI output
  2. In your Notion integration settings, navigate to Webhooks
  3. Enter the Hookdeck Source URL as the Webhook URL
  4. Click Create
  5. Your local endpoint will receive the verification_token — store it securely
  6. Go back to Notion and paste the token to complete verification

Step 5: Configure source verification

  1. Open the Hookdeck Dashboard
  2. Navigate to Connections and select your source
  3. Under Advanced Source Configuration, enable Source Authentication
  4. Select HMAC as the authentication method
  5. Configure the HMAC settings:
    • Algorithm: SHA-256
    • Header: X-Notion-Signature
    • Encoding: Hex
    • Signature Prefix: sha256=
    • Secret: Your Notion verification token
  6. Click Save

How Hookdeck verification works

When verification is enabled:

  1. Hookdeck receives the webhook from Notion
  2. Hookdeck validates the X-Notion-Signature against the payload using your verification token
  3. Valid requests are forwarded to your endpoint with x-hookdeck-verified: true
  4. 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:

  1. Raw body usage: Ensure you're using the exact bytes received, not a parsed/stringified version
  2. Token accuracy: Confirm you're using the correct verification_token from the initial setup
  3. Prefix handling: Remember to strip the sha256= prefix before comparing
  4. 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.