FeaturesTemplatesShowcaseTownie
AI
BlogDocsPricing
Log inSign up
lightweight
lightweightglimpse2-runbook-test
Remix of lightweight/glimpse2-runbook
Public
Like
glimpse2-runbook-test
Home
Code
7
_townie
13
backend
7
frontend
1
shared
1
.vtignore
deno.json
main.tsx
Branches
3
Pull requests
Remixes
History
Environment variables
5
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
_townie
/
03-webhooks.md
Code
/
_townie
/
03-webhooks.md
Search
9/3/2025
Viewing readonly version of main branch: v14
View latest version
03-webhooks.md

Webhook Authentication Architecture Instructions

Townie instructions (Townie should follow these)

Middleware Order

Place authentication middleware in the following order:

  1. Notion webhook auth middleware (X-API-KEY for /tasks/*)
  2. Global user auth middleware (for other protected routes)
  3. Route handlers

Webhook Authentication

Scope

  • Apply only to routes under /tasks/*
  • All /tasks endpoints expect POSTs from Notion webhooks

Implementation

  • Create /backend/routes/webhookAuthCheck.ts
  • Extract X-API-KEY header from incoming requests
  • Compare against NOTION_WEBHOOK_SECRET environment variable
  • Use constant-time comparison to prevent timing attacks
  • Return 401/403 for authentication failures with generic error messages
  • Log failed authentication attempts for monitoring

Route Implementation Requirements

CRITICAL: 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.

Middleware Application (EXACT CODE)

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.

Flow Logic

  • Request to /tasks/* → Check X-API-KEY → If valid, proceed to route handler (skip global auth)
  • Request to /tasks/* → Check X-API-KEY → If invalid, return 401 (never reaches global auth)
  • Request to other routes → Skip webhook auth → Go to global auth → Then route handler

Testing Webhook Authentication

Test in this exact order:

  1. Test without header (should fail):

POST /tasks/test Expected: 401 "Authentication required"

  1. Test with wrong key (should fail):

POST /tasks/test Headers: {"X-API-KEY": "wrong-key"} Expected: 403 "Authentication failed"

  1. Test with correct key (should succeed):

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.

Debugging Webhook Auth Issues

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);
});

Complete Working Example

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}

4. Add Simplified Webhook Testing to Dashboard

Update /backend/routes/views/dashboard.tsx:

Add webhook configuration variables after health data:

// Check webhook configuration
const webhookSecret = Deno.env.get("NOTION_WEBHOOK_SECRET");
const webhookConfigured = !!webhookSecret;
const webhookEndpoint = `${c.req.url.split('/')[0]}//${c.req.url.split('/')[2]}/tasks/notion-webhook`;

Add CSS styles for webhook section (after existing styles):

.webhook-section {
  background: #f8f9fa;
  border: 1px solid #e9ecef;
  border-radius: 6px;
  padding: 20px;
  margin-top: 20px;
}
.webhook-section h3 {
  color: #333;
  margin-top: 0;
  margin-bottom: 15px;
}
.webhook-url {
  margin-bottom: 20px;
}
.webhook-endpoint {
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  font-size: 14px;
  background: white;
  padding: 12px;
  border: 1px solid #dee2e6;
  border-radius: 4px;
  word-break: break-all;
  user-select: all;
}
.test-section {
  border-top: 1px solid #dee2e6;
  padding-top: 20px;
  margin-top: 20px;
}
.test-section p {
  margin-bottom: 15px;
  color: #495057;
}
.test-section code {
  background: #f8f9fa;
  padding: 2px 4px;
  border-radius: 3px;
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  font-size: 13px;
}
.test-controls {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 15px;
}
.test-btn {
  background: #28a745;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.2s;
}
.test-btn:hover {
  background: #218838;
}
.test-btn:disabled {
  background: #6c757d;
  cursor: not-allowed;
}
.api-key-input {
  padding: 6px 10px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 14px;
  width: 200px;
}
.test-result {
  background: white;
  border: 1px solid #dee2e6;
  border-radius: 4px;
  padding: 15px;
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  font-size: 13px;
  min-height: 60px;
  white-space: pre-wrap;
}
.result-success {
  border-color: #28a745;
  background: #d4edda;
}
.result-error {
  border-color: #dc3545;
  background: #f8d7da;
}

Add simplified webhook section HTML (after health section):

<div class="webhook-section">
  <h3>Webhook Endpoint</h3>

  <div class="webhook-url">
    <div class="webhook-endpoint">${webhookEndpoint}</div>
  </div>

  <div class="test-section">
    <p><strong>To test:</strong> Get the <code>NOTION_WEBHOOK_SECRET</code> value from your <code>vars.env</code> file (provided by your administrator)</p>
    <form class="test-controls" onsubmit="testWebhook(event)">
      <input type="text" class="api-key-input" id="customApiKey" placeholder="Enter webhook secret" required>
      <button type="submit" class="test-btn">Test Webhook</button>
    </form>
    <div id="testResult" class="test-result">Ready to test webhook authentication...</div>
  </div>
</div>

Add JavaScript for form submission (before closing body tag):

<script>
  async function testWebhook(event) {
    // Prevent form submission from reloading the page
    if (event) {
      event.preventDefault();
    }

    const resultDiv = document.getElementById('testResult');
    const customKeyInput = document.getElementById('customApiKey');

    // Clear previous result
    resultDiv.className = 'test-result';
    resultDiv.textContent = 'Testing...';

    try {
      const customKey = customKeyInput.value.trim();
      if (!customKey) {
        resultDiv.textContent = 'Please enter the webhook secret to test';
        resultDiv.className = 'test-result result-error';
        return;
      }

      const headers = { 'X-API-KEY': customKey };

      const response = await fetch('/tasks/test', {
        method: 'POST',
        headers: headers
      });

      const responseData = await response.json();
      const timestamp = new Date().toLocaleTimeString();

      if (response.ok) {
        resultDiv.textContent = `[${timestamp}] ✅ Webhook authentication successful!\n\nStatus: ${response.status} ${response.statusText}\nResponse: ${JSON.stringify(responseData, null, 2)}`;
        resultDiv.className = 'test-result result-success';
      } else {
        resultDiv.textContent = `[${timestamp}] ❌ Webhook authentication failed\n\nStatus: ${response.status} ${response.statusText}\nResponse: ${JSON.stringify(responseData, null, 2)}`;
        resultDiv.className = 'test-result result-error';
      }

    } catch (error) {
      const timestamp = new Date().toLocaleTimeString();
      resultDiv.textContent = `[${timestamp}] ❌ Error testing webhook: ${error.message}`;
      resultDiv.className = 'test-result result-error';
    }
  }
</script>

Testing Verification

  1. Test /tasks/debug-webhook (GET) - should show webhook configuration
  2. Test /tasks/test (POST) without header - should return 401
  3. Test /tasks/test (POST) with wrong key - should return 403
  4. Test /tasks/test (POST) with correct key - should return 200
  5. Verify user authentication still works for other routes
  6. Check dashboard shows simplified webhook testing interface
  7. Verify form submission works with both button click and Enter key

Expected Results

Make sure these are true:

  • Webhook endpoints only accessible with correct X-API-KEY
  • User authentication preserved for non-webhook routes
  • Dashboard provides clean, simplified webhook testing interface
  • Form submission works via button click or Enter key
  • All authentication flows working independently
  • Webhook endpoint ready for Notion integration

Security Considerations

  • Webhook middleware should return early (success or failure) without calling next()
  • Never pass webhook requests to the global auth middleware
  • Maintain complete separation between webhook auth and user auth mechanisms
  • Consider rate limiting on /tasks endpoints
  • Use generic error messages that don't reveal authentication mechanism details

Route Structure

  • /tasks/* → Webhook auth required (Notion POSTs)
  • /api/* → User auth required (if applicable)
  • /* → Public routes (static files, etc.)

5. Update Documentation

Update README.md to include webhook authentication section and environment variables.

Final Deliverables

Make sure these are true:

  • Webhook authentication middleware implemented
  • Middleware order correctly configured
  • Test endpoints available
  • Simplified dashboard with webhook testing form
  • Form supports both click and keyboard submission
  • Documentation updated
  • Ready for Notion webhook configuration

Townie, stop here! Before proceeding to additional steps, confirm that this step is working correctly.

If everything is working as intended: conclude this step with these messages:

  1. Briefly summarize the work that was just completed and why it's important
  2. Provide the URL to the dashboard endpoint, from which the user can test the new webhook endpoint with the webhook form
  3. Explain that the user will need the NOTION_WEBHOOK_SECRET from the vars.env to test the webhook endpoint successfully
  4. Explain that we will implement webhooks from Notion in a step that comes later

Next step: create an endpoint to view Glimpse demo data

Tell the user that the next step is to add an endpoint to /routes that returns the data from the Glimpse Demos database in Notion.

Lastly, tell the user to copy this line and paste it into Townie:

Add an endpoint at /views/glimpse/:id by following the instructions in /_townie/04-view.md

Go to top
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Product
FeaturesPricing
Developers
DocsStatusAPI ExamplesNPM Package Examples
Explore
ShowcaseTemplatesNewest ValsTrending ValsNewsletter
Company
AboutBlogCareersBrandhi@val.town
Terms of usePrivacy policyAbuse contact
© 2025 Val Town, Inc.