Place authentication middleware in the following order:
/tasks/*)/backend/routes/webhookAuthCheck.tsCRITICAL: All webhook routes MUST use POST method:
// ✅ CORRECT - Webhooks are POST requests
app.post("/webhook-endpoint", async (c) => {
// Handle webhook
});
// ❌ WRONG - This won't work with webhooks
app.get("/webhook-endpoint", async (c) => {
// Webhooks can't reach this
});
Why: Notion (and most webhook providers) send POST requests, never GET.
Apply middleware in this EXACT order in main.tsx:
// 1. Webhook auth - ONLY for /tasks/* routes
app.use("/tasks/*", webhookAuth);
// 2. User auth - for ALL routes EXCEPT /tasks/*
app.use("/api/*", authCheck);
app.use("/views/*", authCheck);
app.use("/", authCheck);
// Add root route - now serves the authenticated dashboard
app.get("/", dashboardRoute);
// 3. Mount routes AFTER middleware
app.route("/tasks", taskRoutes);
Important: We've changed the global authentication from app.use("*") to more specific auth route handling.
Before:
app.use("*", authCheck); // Applied to ALL routes including /tasks/*
After:
app.use("/api/*", authCheck); // Applied to API routes
app.use("/views/*", authCheck); // Applied to view routes
app.use("/", authCheck); // Applied to root route
This ensures:
✅ / (root) still requires user authentication
✅ /api/* routes still require user authentication
✅ /views/* routes still require user authentication
✅ /tasks/* routes only use webhook authentication (no user auth)
Common mistake: Using app.use("*", webhookAuth) - this applies to all routes incorrectly.
Test in this exact order:
POST /tasks/test Expected: 401 "Authentication required"
POST /tasks/test Headers: {"X-API-KEY": "wrong-key"} Expected: 403 "Authentication failed"
POST /tasks/test
Headers: {"X-API-KEY": "your-configured-secret-value"}
Expected: 200 (reaches your route handler)
If step 3 returns 404: Your route is probably defined as GET instead of POST.
Add this temporary debug endpoint:
app.get("/debug-webhook", (c) => {
const secret = Deno.env.get("NOTION_WEBHOOK_SECRET");
return c.json({
hasSecret: !!secret,
secretLength: secret?.length || 0,
});
});
Expected response: {"hasSecret":true,"secretLength":>0}
Add this catch all endpoint:
// Catch-all handler for undefined webhook endpoints
// This must be the LAST route defined to catch unmatched paths
app.post("*", (c) => {
const path = c.req.path;
const method = c.req.method;
const returnObj = {
error: "Endpoint not found",
path: path,
method: method,
availableEndpoints: [
"GET /tasks/debug-webhook",
"POST /tasks/test",
"POST /tasks/notion-webhook",
],
};
console.log(returnObj);
return c.json(returnObj, 404);
});
Here's a complete working webhook endpoint:
// backend/routes/tasks/_tasks.routes.ts
import { Hono } from "npm:hono@3.12.12";
const app = new Hono();
app.post("/notion-webhook", async (c) => {
// At this point, webhook auth has already passed
const body = await c.req.json();
console.log("Notion webhook received:", body);
// Process the webhook...
return c.json({ success: true });
});
export default app;
Test with:
POST /tasks/notion-webhook Headers: {"X-API-KEY": "your-configured-secret-value"} Body: {"test": "data"} Expected: 200 {"success": true}
These revisions address all the issues we encountered while keeping sensitive values generic and secure.
Townie, stop here! Before proceeding to additional steps, confirm that this step is working correctly.
If everything is working as intended, it's important for the user to go to Notion and test the webhook.
main.tsx, click the green HTTP button, and copy the URL of this Val./tasks/notion-webhook to itX-API-KEY, Value: <x-api-key value>. (Find the value in the NOTION_WEBHOOK_SECRET key in vars.env)Next, tell the user that they can merge this branch into main, then delete this branch, and then they can proceed to the next step: glimpse. Don't write any code.