From 9b60564657a9b6499a43846227ee88bf373e7efc Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 22 Feb 2026 15:01:06 +1100 Subject: [PATCH] Add Webflow webhooks skill with Express, Next.js, and FastAPI examples Includes HMAC-SHA256 signature verification, event handling for form_submission, ecomm_new_order, collection_item_created, and more. All three example test suites pass (Express 15, Next.js 15, FastAPI 17). --- README.md | 1 + providers.yaml | 15 + skills/webflow-webhooks/SKILL.md | 178 ++++++++++ .../examples/express/.env.example | 10 + .../examples/express/README.md | 99 ++++++ .../examples/express/package.json | 23 ++ .../examples/express/src/index.js | 174 +++++++++ .../examples/express/test/webhook.test.js | 259 ++++++++++++++ .../examples/fastapi/.env.example | 11 + .../examples/fastapi/README.md | 113 ++++++ .../webflow-webhooks/examples/fastapi/main.py | 171 +++++++++ .../examples/fastapi/requirements.txt | 6 + .../examples/fastapi/test_webhook.py | 330 ++++++++++++++++++ .../examples/nextjs/.env.example | 7 + .../examples/nextjs/README.md | 110 ++++++ .../nextjs/app/webhooks/webflow/route.ts | 176 ++++++++++ .../examples/nextjs/package.json | 28 ++ .../examples/nextjs/test/webhook.test.ts | 318 +++++++++++++++++ .../examples/nextjs/vitest.config.ts | 10 + .../webflow-webhooks/references/overview.md | 124 +++++++ skills/webflow-webhooks/references/setup.md | 172 +++++++++ .../references/verification.md | 259 ++++++++++++++ 22 files changed, 2594 insertions(+) create mode 100644 skills/webflow-webhooks/SKILL.md create mode 100644 skills/webflow-webhooks/examples/express/.env.example create mode 100644 skills/webflow-webhooks/examples/express/README.md create mode 100644 skills/webflow-webhooks/examples/express/package.json create mode 100644 skills/webflow-webhooks/examples/express/src/index.js create mode 100644 skills/webflow-webhooks/examples/express/test/webhook.test.js create mode 100644 skills/webflow-webhooks/examples/fastapi/.env.example create mode 100644 skills/webflow-webhooks/examples/fastapi/README.md create mode 100644 skills/webflow-webhooks/examples/fastapi/main.py create mode 100644 skills/webflow-webhooks/examples/fastapi/requirements.txt create mode 100644 skills/webflow-webhooks/examples/fastapi/test_webhook.py create mode 100644 skills/webflow-webhooks/examples/nextjs/.env.example create mode 100644 skills/webflow-webhooks/examples/nextjs/README.md create mode 100644 skills/webflow-webhooks/examples/nextjs/app/webhooks/webflow/route.ts create mode 100644 skills/webflow-webhooks/examples/nextjs/package.json create mode 100644 skills/webflow-webhooks/examples/nextjs/test/webhook.test.ts create mode 100644 skills/webflow-webhooks/examples/nextjs/vitest.config.ts create mode 100644 skills/webflow-webhooks/references/overview.md create mode 100644 skills/webflow-webhooks/references/setup.md create mode 100644 skills/webflow-webhooks/references/verification.md diff --git a/README.md b/README.md index 783b9d7..d37db28 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | Shopify | [`shopify-webhooks`](skills/shopify-webhooks/) | Verify Shopify HMAC signatures, handle order and product webhook events | | Stripe | [`stripe-webhooks`](skills/stripe-webhooks/) | Verify Stripe webhook signatures, parse payment event payloads, handle checkout.session.completed events | | Vercel | [`vercel-webhooks`](skills/vercel-webhooks/) | Verify Vercel webhook signatures (HMAC-SHA1), handle deployment and project events | +| Webflow | [`webflow-webhooks`](skills/webflow-webhooks/) | Verify Webflow webhook signatures (HMAC-SHA256), handle form submission, ecommerce, and CMS events | | WooCommerce | [`woocommerce-webhooks`](skills/woocommerce-webhooks/) | Verify WooCommerce webhook signatures, handle order, product, and customer events | ### Webhook Handler Pattern Skills diff --git a/providers.yaml b/providers.yaml index c934aa1..a3ecc71 100644 --- a/providers.yaml +++ b/providers.yaml @@ -275,6 +275,21 @@ providers: - deployment.created - deployment.succeeded + - name: webflow + displayName: Webflow + docs: + webhooks: https://developers.webflow.com/data/docs/working-with-webhooks + verification: https://developers.webflow.com/data/docs/working-with-webhooks#validating-request-signatures + notes: > + Website builder and CMS platform. Uses x-webflow-signature and x-webflow-timestamp headers + with HMAC-SHA256 (hex encoded). Signed content format is timestamp:body. Only webhooks + created via OAuth apps or API include signature headers; dashboard-created webhooks do not. + Common events: form_submission, site_publish, ecomm_new_order, collection_item_created. + testScenario: + events: + - form_submission + - ecomm_new_order + - name: woocommerce displayName: WooCommerce docs: diff --git a/skills/webflow-webhooks/SKILL.md b/skills/webflow-webhooks/SKILL.md new file mode 100644 index 0000000..150befd --- /dev/null +++ b/skills/webflow-webhooks/SKILL.md @@ -0,0 +1,178 @@ +--- +name: webflow-webhooks +description: Receive and verify Webflow webhooks. Use when setting up Webflow webhook handlers, debugging signature verification, or handling Webflow events like form_submission, site_publish, ecomm_new_order, or collection item changes. +license: MIT +metadata: + author: hookdeck + version: "0.1.0" + repository: https://github.com/hookdeck/webhook-skills +--- + +# Webflow Webhooks + +## When to Use This Skill + +- How do I receive Webflow webhooks? +- How do I verify Webflow webhook signatures? +- How do I handle form_submission events from Webflow? +- How do I process Webflow ecommerce order events? +- Why is my Webflow webhook signature verification failing? +- Setting up Webflow CMS collection item webhooks + +## Essential Code + +### Signature Verification (Manual) + +```javascript +const crypto = require('crypto'); + +function verifyWebflowSignature(rawBody, signature, timestamp, secret) { + // Check timestamp to prevent replay attacks (5 minute window - 300000 milliseconds) + const currentTime = Date.now(); + if (Math.abs(currentTime - parseInt(timestamp)) > 300000) { + return false; + } + + // Generate HMAC signature + const signedContent = `${timestamp}:${rawBody}`; + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(signedContent) + .digest('hex'); + + // Timing-safe comparison + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch { + return false; // Different lengths = invalid + } +} +``` + +### Processing Events + +```javascript +app.post('/webhooks/webflow', express.raw({ type: 'application/json' }), (req, res) => { + const signature = req.headers['x-webflow-signature']; + const timestamp = req.headers['x-webflow-timestamp']; + + if (!signature || !timestamp) { + return res.status(400).send('Missing required headers'); + } + + // Verify signature (use OAuth client secret or webhook-specific secret) + const isValid = verifyWebflowSignature( + req.body.toString(), + signature, + timestamp, + process.env.WEBFLOW_WEBHOOK_SECRET + ); + + if (!isValid) { + return res.status(400).send('Invalid signature'); + } + + // Parse the verified payload + const event = JSON.parse(req.body); + + // Handle different event types + switch (event.triggerType) { + case 'form_submission': + console.log('New form submission:', event.payload.data); + break; + case 'ecomm_new_order': + console.log('New order:', event.payload); + break; + case 'collection_item_created': + console.log('New CMS item:', event.payload); + break; + // Add more event handlers as needed + } + + // Always return 200 to acknowledge receipt + res.status(200).send('OK'); +}); +``` + +## Common Event Types + +| Event | Triggered When | Use Case | +|-------|----------------|----------| +| `form_submission` | Form submitted on site | Contact forms, lead capture | +| `site_publish` | Site is published | Clear caches, trigger builds | +| `ecomm_new_order` | New ecommerce order | Order processing, inventory | +| `ecomm_order_changed` | Order status changes | Update fulfillment systems | +| `collection_item_created` | CMS item created | Content syndication | +| `collection_item_changed` | CMS item updated | Update external systems | +| `collection_item_deleted` | CMS item deleted | Remove from external systems | + +## Environment Variables + +```bash +# For webhooks created via OAuth App +WEBFLOW_WEBHOOK_SECRET=your_oauth_client_secret + +# For webhooks created via API (after April 2025) +WEBFLOW_WEBHOOK_SECRET=whsec_xxxxx # Returned when creating webhook +``` + +## Local Development + +For local webhook testing, install Hookdeck CLI: + +```bash +# Install via npm +npm install -g hookdeck-cli + +# Or via Homebrew +brew install hookdeck/hookdeck/hookdeck +``` + +Then start the tunnel: + +```bash +hookdeck listen 3000 --path /webhooks/webflow +``` + +No account required. Provides local tunnel + web UI for inspecting requests. + +## Resources + +- [What Are Webflow Webhooks](references/overview.md) - Event types and payload structure +- [Setting Up Webflow Webhooks](references/setup.md) - Dashboard configuration and API setup +- [Signature Verification Details](references/verification.md) - In-depth verification guide +- [Express Example](examples/express/) - Node.js implementation with tests +- [Next.js Example](examples/nextjs/) - App Router implementation +- [FastAPI Example](examples/fastapi/) - Python implementation + +## Important Notes + +- Webhooks created through the Webflow dashboard do NOT include signature headers +- Only webhooks created via OAuth apps or API include `x-webflow-signature` and `x-webflow-timestamp` +- Always use raw body for signature verification, not parsed JSON +- Timestamp validation (5 minute window - 300000 milliseconds) is critical to prevent replay attacks +- Return 200 status to acknowledge receipt; other statuses trigger retries (up to 3 times) + +## Recommended: webhook-handler-patterns + +This skill pairs well with webhook-handler-patterns for production-ready implementations: + +- [Handler sequence](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/handler-sequence.md) — Request flow and middleware order +- [Idempotency](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/idempotency.md) — Handling duplicate events +- [Error handling](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/error-handling.md) — Graceful failure and recovery +- [Retry logic](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/retry-logic.md) — Handling failed processing + +## Related Skills + +- [stripe-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/stripe-webhooks) - Stripe webhook handling with similar HMAC-SHA256 verification +- [shopify-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/shopify-webhooks) - Shopify webhook implementation +- [github-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/github-webhooks) - GitHub webhook handling +- [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) - Production patterns for all webhooks +- [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) - Webhook infrastructure and reliability + +Sources: +- [Working with Webhooks – Webflow Docs](https://developers.webflow.com/data/docs/working-with-webhooks) +- [Webhook Signatures Changelog](https://developers.webflow.com/data/changelog/webhook-signatures) \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/express/.env.example b/skills/webflow-webhooks/examples/express/.env.example new file mode 100644 index 0000000..5cd9ab6 --- /dev/null +++ b/skills/webflow-webhooks/examples/express/.env.example @@ -0,0 +1,10 @@ +# For webhooks created via OAuth App +# Use your OAuth app's client secret +WEBFLOW_WEBHOOK_SECRET=your_oauth_client_secret + +# For webhooks created via API (after April 2025) +# Use the webhook-specific secret returned when creating the webhook +# Format: whsec_xxxxxxxxxxxxx + +# Server port (optional) +PORT=3000 \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/express/README.md b/skills/webflow-webhooks/examples/express/README.md new file mode 100644 index 0000000..9de1ea9 --- /dev/null +++ b/skills/webflow-webhooks/examples/express/README.md @@ -0,0 +1,99 @@ +# Webflow Webhooks - Express Example + +Minimal example of receiving Webflow webhooks with signature verification using Express. + +## Prerequisites + +- Node.js 18+ +- Webflow account with webhook configured +- Webhook signing secret (from OAuth app or API-created webhook) + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add your Webflow webhook signing secret to `.env`: + - For OAuth app webhooks: Use your OAuth client secret + - For API-created webhooks: Use the `secret` field from the creation response + +## Run + +```bash +# Production +npm start + +# Development (with auto-reload) +npm run dev +``` + +Server runs on http://localhost:3000 + +Webhook endpoint: `POST http://localhost:3000/webhooks/webflow` + +## Test Locally + +Use Hookdeck CLI to create a public URL for your local server: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Create tunnel +hookdeck listen 3000 --path /webhooks/webflow +``` + +1. Copy the Hookdeck URL (e.g., `https://events.hookdeck.com/e/src_xxxxx`) +2. Add this URL as your webhook endpoint in Webflow +3. Trigger test events in Webflow +4. View requests in the Hookdeck dashboard + +## Test Suite + +Run the test suite: + +```bash +npm test +``` + +Tests verify: +- Signature verification with valid signatures +- Rejection of invalid signatures +- Timestamp validation (5-minute window) +- Proper error handling +- Event type handling + +## Project Structure + +``` +├── src/ +│ └── index.js # Express server and webhook handler +├── test/ +│ └── webhook.test.js # Test suite +├── .env.example # Environment variables template +├── package.json # Dependencies +└── README.md # This file +``` + +## Common Issues + +### Signature Verification Fails +- Ensure you're using the correct secret (OAuth client secret vs webhook-specific secret) +- Webhook must be created via API or OAuth app (not dashboard) for signatures +- Check that the raw body is being used (not parsed JSON) + +### Missing Headers +- Dashboard-created webhooks don't include signature headers +- Recreate the webhook via API or OAuth app + +### Webhook Not Received +- Verify the endpoint URL is correct +- Check Webflow webhook logs for delivery attempts +- Ensure your server returns 200 status for successful receipt \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/express/package.json b/skills/webflow-webhooks/examples/express/package.json new file mode 100644 index 0000000..87f6c8f --- /dev/null +++ b/skills/webflow-webhooks/examples/express/package.json @@ -0,0 +1,23 @@ +{ + "name": "webflow-webhooks-express", + "version": "1.0.0", + "description": "Express webhook handler for Webflow", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "test": "jest" + }, + "keywords": ["webflow", "webhooks", "express"], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^5.2.1", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^6.3.4", + "nodemon": "^3.1.0" + } +} \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/express/src/index.js b/skills/webflow-webhooks/examples/express/src/index.js new file mode 100644 index 0000000..24a6ef5 --- /dev/null +++ b/skills/webflow-webhooks/examples/express/src/index.js @@ -0,0 +1,174 @@ +const express = require('express'); +const crypto = require('crypto'); +require('dotenv').config(); + +const app = express(); +const port = process.env.PORT || 3000; + +/** + * Verify Webflow webhook signature + * @param {Buffer|string} rawBody - Raw request body + * @param {string} signature - x-webflow-signature header + * @param {string} timestamp - x-webflow-timestamp header + * @param {string} secret - Webhook signing secret + * @returns {boolean} - Whether signature is valid + */ +function verifyWebflowSignature(rawBody, signature, timestamp, secret) { + // Validate timestamp to prevent replay attacks (5-minute window) + const currentTime = Date.now(); + const webhookTime = parseInt(timestamp); + + if (isNaN(webhookTime)) { + return false; + } + + const timeDiff = Math.abs(currentTime - webhookTime); + if (timeDiff > 300000) { // 5 minutes = 300000 milliseconds + return false; + } + + // Create signed content: timestamp:body + const signedContent = `${timestamp}:${rawBody.toString()}`; + + // Generate expected signature + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(signedContent) + .digest('hex'); + + // Timing-safe comparison + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch (error) { + // Different lengths = invalid + return false; + } +} + +// Webhook endpoint with raw body parsing +app.post('/webhooks/webflow', express.raw({ type: 'application/json' }), async (req, res) => { + const signature = req.headers['x-webflow-signature']; + const timestamp = req.headers['x-webflow-timestamp']; + + // Check required headers + if (!signature || !timestamp) { + console.error('Missing required headers'); + return res.status(400).send('Missing required headers'); + } + + // Verify signature + const secret = process.env.WEBFLOW_WEBHOOK_SECRET; + if (!secret) { + console.error('WEBFLOW_WEBHOOK_SECRET not configured'); + return res.status(500).send('Webhook secret not configured'); + } + + const isValid = verifyWebflowSignature(req.body, signature, timestamp, secret); + + if (!isValid) { + console.error('Invalid webhook signature'); + return res.status(400).send('Invalid signature'); + } + + // Parse the verified payload + let event; + try { + event = JSON.parse(req.body.toString()); + } catch (error) { + console.error('Failed to parse webhook body:', error); + return res.status(400).send('Invalid JSON'); + } + + // Log the event + console.log('Received Webflow webhook:', { + type: event.triggerType, + timestamp: new Date(parseInt(timestamp)).toISOString() + }); + + // Handle different event types + try { + switch (event.triggerType) { + case 'form_submission': + console.log('Form submission:', { + formName: event.payload.name, + submittedAt: event.payload.submittedAt, + data: event.payload.data + }); + // Add your form submission handling logic here + break; + + case 'ecomm_new_order': + console.log('New order:', { + orderId: event.payload.orderId, + total: event.payload.total, + currency: event.payload.currency + }); + // Add your order processing logic here + break; + + case 'collection_item_created': + console.log('New CMS item:', { + id: event.payload._id, + name: event.payload.name, + collection: event.payload._cid + }); + // Add your CMS sync logic here + break; + + case 'collection_item_changed': + console.log('CMS item updated:', { + id: event.payload._id, + name: event.payload.name + }); + // Add your CMS update sync logic here + break; + + case 'collection_item_deleted': + console.log('CMS item deleted:', { + id: event.payload._id + }); + // Add your CMS deletion sync logic here + break; + + case 'site_publish': + console.log('Site published'); + // Add cache clearing or build trigger logic here + break; + + case 'user_account_added': + console.log('New user account:', { + userId: event.payload.userId + }); + // Add your user account creation logic here + break; + + default: + console.log('Unhandled event type:', event.triggerType); + } + + // Always return 200 to acknowledge receipt + res.status(200).send('OK'); + } catch (error) { + console.error('Error processing webhook:', error); + // Still return 200 to prevent retries if we've verified the signature + res.status(200).send('OK'); + } +}); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Only start server if not in test mode +if (process.env.NODE_ENV !== 'test') { + app.listen(port, () => { + console.log(`Webflow webhook handler listening on port ${port}`); + console.log(`Webhook endpoint: POST http://localhost:${port}/webhooks/webflow`); + }); +} + +module.exports = { app, verifyWebflowSignature }; \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/express/test/webhook.test.js b/skills/webflow-webhooks/examples/express/test/webhook.test.js new file mode 100644 index 0000000..23d09f9 --- /dev/null +++ b/skills/webflow-webhooks/examples/express/test/webhook.test.js @@ -0,0 +1,259 @@ +const request = require('supertest'); +const crypto = require('crypto'); +const { app, verifyWebflowSignature } = require('../src/index'); + +// Set test environment +process.env.NODE_ENV = 'test'; +process.env.WEBFLOW_WEBHOOK_SECRET = 'test_webhook_secret_key'; + +describe('Webflow Webhook Handler', () => { + const webhookSecret = process.env.WEBFLOW_WEBHOOK_SECRET; + + // Helper to generate valid signature + function generateSignature(payload, timestamp, secret = webhookSecret) { + const signedContent = `${timestamp}:${payload}`; + return crypto + .createHmac('sha256', secret) + .update(signedContent) + .digest('hex'); + } + + // Helper to create test webhook request + function createWebhookRequest(payload, options = {}) { + const timestamp = options.timestamp || Date.now().toString(); + const secret = options.secret || webhookSecret; + const signature = options.signature || generateSignature(payload, timestamp, secret); + + return request(app) + .post('/webhooks/webflow') + .set('x-webflow-signature', signature) + .set('x-webflow-timestamp', timestamp) + .set('Content-Type', 'application/json') + .send(payload); + } + + describe('POST /webhooks/webflow', () => { + it('should accept valid webhook with correct signature', async () => { + const payload = JSON.stringify({ + triggerType: 'form_submission', + payload: { + name: 'Contact Form', + siteId: '123456', + data: { + email: 'test@example.com', + message: 'Test message' + }, + submittedAt: '2024-01-15T12:00:00.000Z', + id: 'form123' + } + }); + + const response = await createWebhookRequest(payload); + + expect(response.status).toBe(200); + expect(response.text).toBe('OK'); + }); + + it('should handle different event types', async () => { + const eventTypes = [ + { + triggerType: 'ecomm_new_order', + payload: { + orderId: 'order123', + total: 99.99, + currency: 'USD' + } + }, + { + triggerType: 'collection_item_created', + payload: { + _id: 'item123', + name: 'New Item', + _cid: 'collection123' + } + }, + { + triggerType: 'site_publish', + payload: {} + } + ]; + + for (const event of eventTypes) { + const response = await createWebhookRequest(JSON.stringify(event)); + expect(response.status).toBe(200); + } + }); + + it('should reject webhook with invalid signature', async () => { + const payload = JSON.stringify({ + triggerType: 'form_submission', + payload: { test: 'data' } + }); + + const response = await createWebhookRequest(payload, { + signature: 'invalid_signature' + }); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid signature'); + }); + + it('should reject webhook with missing signature header', async () => { + const response = await request(app) + .post('/webhooks/webflow') + .set('x-webflow-timestamp', Date.now().toString()) + .set('Content-Type', 'application/json') + .send('{}'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Missing required headers'); + }); + + it('should reject webhook with missing timestamp header', async () => { + const response = await request(app) + .post('/webhooks/webflow') + .set('x-webflow-signature', 'some_signature') + .set('Content-Type', 'application/json') + .send('{}'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Missing required headers'); + }); + + it('should reject webhook with expired timestamp', async () => { + const payload = JSON.stringify({ triggerType: 'test', payload: {} }); + const oldTimestamp = (Date.now() - 400000).toString(); // 6+ minutes old (400000 ms) + + const response = await createWebhookRequest(payload, { + timestamp: oldTimestamp + }); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid signature'); + }); + + it('should accept webhook with timestamp within 5-minute window', async () => { + const payload = JSON.stringify({ triggerType: 'test', payload: {} }); + const recentTimestamp = (Date.now() - 250000).toString(); // 4 minutes old (250000 ms) + + const response = await createWebhookRequest(payload, { + timestamp: recentTimestamp + }); + + expect(response.status).toBe(200); + }); + + it('should reject webhook with wrong secret', async () => { + const payload = JSON.stringify({ triggerType: 'test', payload: {} }); + + const response = await createWebhookRequest(payload, { + secret: 'wrong_secret' + }); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid signature'); + }); + + it('should handle invalid JSON gracefully', async () => { + const invalidJson = 'not valid json'; + const timestamp = Date.now().toString(); + const signature = generateSignature(invalidJson, timestamp); + + const response = await request(app) + .post('/webhooks/webflow') + .set('x-webflow-signature', signature) + .set('x-webflow-timestamp', timestamp) + .set('Content-Type', 'application/json') + .send(invalidJson); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid JSON'); + }); + }); + + describe('verifyWebflowSignature', () => { + it('should verify valid signature', () => { + const payload = 'test payload'; + const timestamp = Date.now().toString(); + const signature = generateSignature(payload, timestamp); + + const isValid = verifyWebflowSignature( + Buffer.from(payload), + signature, + timestamp, + webhookSecret + ); + + expect(isValid).toBe(true); + }); + + it('should reject invalid signature', () => { + const payload = 'test payload'; + const timestamp = Date.now().toString(); + + const isValid = verifyWebflowSignature( + Buffer.from(payload), + 'invalid_signature', + timestamp, + webhookSecret + ); + + expect(isValid).toBe(false); + }); + + it('should reject expired timestamp', () => { + const payload = 'test payload'; + const oldTimestamp = (Date.now() - 400000).toString(); + const signature = generateSignature(payload, oldTimestamp); + + const isValid = verifyWebflowSignature( + Buffer.from(payload), + signature, + oldTimestamp, + webhookSecret + ); + + expect(isValid).toBe(false); + }); + + it('should handle invalid timestamp format', () => { + const payload = 'test payload'; + const signature = generateSignature(payload, 'not-a-number'); + + const isValid = verifyWebflowSignature( + Buffer.from(payload), + signature, + 'not-a-number', + webhookSecret + ); + + expect(isValid).toBe(false); + }); + + it('should handle signatures of different lengths', () => { + const payload = 'test payload'; + const timestamp = Date.now().toString(); + const validSignature = generateSignature(payload, timestamp); + const shortSignature = validSignature.substring(0, 10); + + const isValid = verifyWebflowSignature( + Buffer.from(payload), + shortSignature, + timestamp, + webhookSecret + ); + + expect(isValid).toBe(false); + }); + }); + + describe('GET /health', () => { + it('should return health check', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('status', 'ok'); + expect(response.body).toHaveProperty('timestamp'); + }); + }); +}); \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/fastapi/.env.example b/skills/webflow-webhooks/examples/fastapi/.env.example new file mode 100644 index 0000000..50a35bc --- /dev/null +++ b/skills/webflow-webhooks/examples/fastapi/.env.example @@ -0,0 +1,11 @@ +# For webhooks created via OAuth App +# Use your OAuth app's client secret +WEBFLOW_WEBHOOK_SECRET=your_oauth_client_secret + +# For webhooks created via API (after April 2025) +# Use the webhook-specific secret returned when creating the webhook +# Format: whsec_xxxxxxxxxxxxx + +# Server host and port (optional) +HOST=0.0.0.0 +PORT=3000 \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/fastapi/README.md b/skills/webflow-webhooks/examples/fastapi/README.md new file mode 100644 index 0000000..60338be --- /dev/null +++ b/skills/webflow-webhooks/examples/fastapi/README.md @@ -0,0 +1,113 @@ +# Webflow Webhooks - FastAPI Example + +Minimal example of receiving Webflow webhooks with signature verification using FastAPI. + +## Prerequisites + +- Python 3.9+ +- Webflow account with webhook configured +- Webhook signing secret (from OAuth app or API-created webhook) + +## Setup + +1. Create a virtual environment: + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Copy environment variables: + ```bash + cp .env.example .env + ``` + +4. Add your Webflow webhook signing secret to `.env`: + - For OAuth app webhooks: Use your OAuth client secret + - For API-created webhooks: Use the `secret` field from the creation response + +## Run + +```bash +# Production +uvicorn main:app --host 0.0.0.0 --port 3000 + +# Development (with auto-reload) +uvicorn main:app --reload --host 0.0.0.0 --port 3000 +``` + +Server runs on http://localhost:3000 + +Webhook endpoint: `POST http://localhost:3000/webhooks/webflow` + +API documentation: http://localhost:3000/docs + +## Test Locally + +Use Hookdeck CLI to create a public URL for your local server: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Create tunnel +hookdeck listen 3000 --path /webhooks/webflow +``` + +1. Copy the Hookdeck URL (e.g., `https://events.hookdeck.com/e/src_xxxxx`) +2. Add this URL as your webhook endpoint in Webflow +3. Trigger test events in Webflow +4. Check your FastAPI console for logged events + +## Test Suite + +Run the test suite: + +```bash +pytest test_webhook.py -v +``` + +Tests verify: +- Signature verification with valid signatures +- Rejection of invalid signatures +- Timestamp validation (5-minute window) +- Proper error handling +- Event type handling + +## Project Structure + +``` +├── main.py # FastAPI application and webhook handler +├── test_webhook.py # Test suite +├── requirements.txt # Python dependencies +├── .env.example # Environment variables template +└── README.md # This file +``` + +## Implementation Notes + +This example demonstrates: +- Raw body access for signature verification +- Dependency injection for signature validation +- Async request handlers +- Proper error responses with appropriate status codes +- Comprehensive logging + +## Common Issues + +### Signature Verification Fails +- Ensure you're using the correct secret (OAuth client secret vs webhook-specific secret) +- Webhook must be created via API or OAuth app (not dashboard) for signatures +- Verify you're using the raw request body + +### Missing Headers +- Dashboard-created webhooks don't include signature headers +- Recreate the webhook via API or OAuth app + +### Module Import Errors +- Ensure virtual environment is activated +- Run `pip install -r requirements.txt` to install all dependencies \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/fastapi/main.py b/skills/webflow-webhooks/examples/fastapi/main.py new file mode 100644 index 0000000..b98c3cb --- /dev/null +++ b/skills/webflow-webhooks/examples/fastapi/main.py @@ -0,0 +1,171 @@ +import os +import hmac +import hashlib +import time +import json +from typing import Optional +from fastapi import FastAPI, Request, HTTPException, Depends, Header +from fastapi.responses import Response +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +app = FastAPI(title="Webflow Webhook Handler") + +def get_webhook_secret() -> str: + """Get webhook secret from environment at request time.""" + return os.getenv("WEBFLOW_WEBHOOK_SECRET", "") + + +def verify_webflow_signature( + raw_body: bytes, + signature: str, + timestamp: str, + secret: str +) -> bool: + """Verify Webflow webhook signature""" + + # Validate timestamp to prevent replay attacks (5-minute window) + try: + webhook_time = int(timestamp) + except ValueError: + return False + + current_time = int(time.time() * 1000) + time_diff = abs(current_time - webhook_time) + + if time_diff > 300000: # 5 minutes = 300000 milliseconds + return False + + # Create signed content: timestamp:body + signed_content = f"{timestamp}:{raw_body.decode('utf-8')}" + + # Generate expected signature + expected_signature = hmac.new( + secret.encode('utf-8'), + signed_content.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + # Timing-safe comparison + return hmac.compare_digest(signature, expected_signature) + + +async def validate_webhook_signature( + request: Request, + x_webflow_signature: Optional[str] = Header(None), + x_webflow_timestamp: Optional[str] = Header(None) +) -> bytes: + """FastAPI dependency to validate webhook signature""" + + # Check required headers + if not x_webflow_signature or not x_webflow_timestamp: + raise HTTPException(status_code=400, detail="Missing required headers") + + # Check webhook secret + secret = get_webhook_secret() + if not secret: + raise HTTPException(status_code=500, detail="Webhook secret not configured") + + # Get raw body + raw_body = await request.body() + + # Verify signature + is_valid = verify_webflow_signature( + raw_body, + x_webflow_signature, + x_webflow_timestamp, + secret + ) + + if not is_valid: + raise HTTPException(status_code=400, detail="Invalid signature") + + return raw_body + + +@app.post("/webhooks/webflow") +async def handle_webhook(raw_body: bytes = Depends(validate_webhook_signature)): + """Handle Webflow webhooks with signature verification""" + + # Parse the verified payload + try: + event = json.loads(raw_body.decode('utf-8')) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON") + + # Log the event + print(f"Received Webflow webhook: {event.get('triggerType')}") + + # Handle different event types + trigger_type = event.get('triggerType') + payload = event.get('payload', {}) + + if trigger_type == 'form_submission': + print(f"Form submission: {payload.get('name')}") + print(f"Form data: {payload.get('data')}") + # Add your form submission handling logic here + + elif trigger_type == 'ecomm_new_order': + print(f"New order: {payload.get('orderId')}") + print(f"Total: {payload.get('total')} {payload.get('currency')}") + # Add your order processing logic here + + elif trigger_type == 'collection_item_created': + print(f"New CMS item: {payload.get('name')}") + print(f"Collection: {payload.get('_cid')}") + # Add your CMS sync logic here + + elif trigger_type == 'collection_item_changed': + print(f"CMS item updated: {payload.get('name')}") + + elif trigger_type == 'collection_item_deleted': + print(f"CMS item deleted: {payload.get('_id')}") + + elif trigger_type == 'site_publish': + print("Site published") + # Add cache clearing or build trigger logic here + + elif trigger_type == 'user_account_added': + print(f"New user account: {payload.get('userId')}") + + else: + print(f"Unhandled event type: {trigger_type}") + + # Always return 200 to acknowledge receipt + return {"received": True} + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return { + "status": "healthy", + "timestamp": int(time.time()) + } + + +@app.get("/") +async def root(): + """Root endpoint with API information""" + return { + "name": "Webflow Webhook Handler", + "endpoints": { + "webhook": "POST /webhooks/webflow", + "health": "GET /health", + "docs": "GET /docs" + } + } + + +if __name__ == "__main__": + import uvicorn + + host = os.getenv("HOST", "0.0.0.0") + port = int(os.getenv("PORT", 3000)) + + print(f"Starting Webflow webhook handler on {host}:{port}") + print(f"Webhook endpoint: POST http://{host}:{port}/webhooks/webflow") + + uvicorn.run(app, host=host, port=port) \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/fastapi/requirements.txt b/skills/webflow-webhooks/examples/fastapi/requirements.txt new file mode 100644 index 0000000..ef85cad --- /dev/null +++ b/skills/webflow-webhooks/examples/fastapi/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115.0 +uvicorn>=0.30.0 +python-dotenv>=1.0.0 +pytest>=8.0.0 +httpx>=0.27.0 +pytest-asyncio>=0.23.0 \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/fastapi/test_webhook.py b/skills/webflow-webhooks/examples/fastapi/test_webhook.py new file mode 100644 index 0000000..7f41f13 --- /dev/null +++ b/skills/webflow-webhooks/examples/fastapi/test_webhook.py @@ -0,0 +1,330 @@ +import os +import json +import hmac +import hashlib +import time +import pytest +from fastapi.testclient import TestClient +from main import app, verify_webflow_signature + +# Set test environment +os.environ["WEBFLOW_WEBHOOK_SECRET"] = "test_webhook_secret_key" + +client = TestClient(app) +webhook_secret = "test_webhook_secret_key" + + +def generate_signature(payload: str, timestamp: str, secret: str = webhook_secret) -> str: + """Generate a valid Webflow signature for testing""" + signed_content = f"{timestamp}:{payload}" + return hmac.new( + secret.encode('utf-8'), + signed_content.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + +def create_webhook_request( + payload: dict, + timestamp: str = None, + signature: str = None, + secret: str = None +) -> dict: + """Create webhook request headers and body""" + payload_str = json.dumps(payload) + timestamp = timestamp or str(int(time.time() * 1000)) + signature = signature or generate_signature(payload_str, timestamp, secret or webhook_secret) + + return { + "headers": { + "x-webflow-signature": signature, + "x-webflow-timestamp": timestamp, + "content-type": "application/json" + }, + "data": payload_str + } + + +class TestWebflowWebhook: + def test_valid_webhook(self): + """Test webhook with valid signature""" + payload = { + "triggerType": "form_submission", + "payload": { + "name": "Contact Form", + "siteId": "123456", + "data": { + "email": "test@example.com", + "message": "Test message" + }, + "submittedAt": "2024-01-15T12:00:00.000Z", + "id": "form123" + } + } + + request_data = create_webhook_request(payload) + response = client.post( + "/webhooks/webflow", + headers=request_data["headers"], + content=request_data["data"] + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_different_event_types(self): + """Test handling of different event types""" + event_types = [ + { + "triggerType": "ecomm_new_order", + "payload": { + "orderId": "order123", + "total": 99.99, + "currency": "USD" + } + }, + { + "triggerType": "collection_item_created", + "payload": { + "_id": "item123", + "name": "New Item", + "_cid": "collection123" + } + }, + { + "triggerType": "site_publish", + "payload": {} + }, + { + "triggerType": "user_account_added", + "payload": { + "userId": "user123" + } + } + ] + + for event in event_types: + request_data = create_webhook_request(event) + response = client.post( + "/webhooks/webflow", + headers=request_data["headers"], + content=request_data["data"] + ) + assert response.status_code == 200 + + def test_invalid_signature(self): + """Test webhook with invalid signature""" + payload = {"triggerType": "test", "payload": {}} + request_data = create_webhook_request(payload, signature="invalid_signature") + + response = client.post( + "/webhooks/webflow", + headers=request_data["headers"], + content=request_data["data"] + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid signature" + + def test_missing_signature_header(self): + """Test webhook with missing signature header""" + response = client.post( + "/webhooks/webflow", + headers={ + "x-webflow-timestamp": str(int(time.time() * 1000)), + "content-type": "application/json" + }, + json={"triggerType": "test", "payload": {}} + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Missing required headers" + + def test_missing_timestamp_header(self): + """Test webhook with missing timestamp header""" + response = client.post( + "/webhooks/webflow", + headers={ + "x-webflow-signature": "some_signature", + "content-type": "application/json" + }, + json={"triggerType": "test", "payload": {}} + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Missing required headers" + + def test_expired_timestamp(self): + """Test webhook with expired timestamp (older than 5 minutes)""" + payload = {"triggerType": "test", "payload": {}} + old_timestamp = str(int(time.time() * 1000) - 400000) # 6+ minutes old (400000 ms) + request_data = create_webhook_request(payload, timestamp=old_timestamp) + + response = client.post( + "/webhooks/webflow", + headers=request_data["headers"], + content=request_data["data"] + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid signature" + + def test_recent_timestamp(self): + """Test webhook with timestamp within 5-minute window""" + payload = {"triggerType": "test", "payload": {}} + recent_timestamp = str(int(time.time() * 1000) - 250000) # 4 minutes old (250000 ms) + request_data = create_webhook_request(payload, timestamp=recent_timestamp) + + response = client.post( + "/webhooks/webflow", + headers=request_data["headers"], + content=request_data["data"] + ) + + assert response.status_code == 200 + + def test_wrong_secret(self): + """Test webhook with wrong secret""" + payload = {"triggerType": "test", "payload": {}} + request_data = create_webhook_request(payload, secret="wrong_secret") + + response = client.post( + "/webhooks/webflow", + headers=request_data["headers"], + content=request_data["data"] + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid signature" + + def test_invalid_json(self): + """Test webhook with invalid JSON""" + timestamp = str(int(time.time() * 1000)) + invalid_json = "not valid json" + signature = generate_signature(invalid_json, timestamp) + + response = client.post( + "/webhooks/webflow", + headers={ + "x-webflow-signature": signature, + "x-webflow-timestamp": timestamp, + "content-type": "application/json" + }, + content=invalid_json + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid JSON" + + def test_health_endpoint(self): + """Test health check endpoint""" + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert "timestamp" in data + + def test_root_endpoint(self): + """Test root endpoint""" + response = client.get("/") + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Webflow Webhook Handler" + assert "endpoints" in data + + +class TestSignatureVerification: + def test_verify_valid_signature(self): + """Test signature verification with valid inputs""" + payload = "test payload" + timestamp = str(int(time.time() * 1000)) + signature = generate_signature(payload, timestamp) + + is_valid = verify_webflow_signature( + payload.encode('utf-8'), + signature, + timestamp, + webhook_secret + ) + + assert is_valid is True + + def test_verify_invalid_signature(self): + """Test signature verification with invalid signature""" + payload = "test payload" + timestamp = str(int(time.time() * 1000)) + + is_valid = verify_webflow_signature( + payload.encode('utf-8'), + "invalid_signature", + timestamp, + webhook_secret + ) + + assert is_valid is False + + def test_verify_expired_timestamp(self): + """Test signature verification with expired timestamp""" + payload = "test payload" + old_timestamp = str(int(time.time() * 1000) - 400000) + signature = generate_signature(payload, old_timestamp) + + is_valid = verify_webflow_signature( + payload.encode('utf-8'), + signature, + old_timestamp, + webhook_secret + ) + + assert is_valid is False + + def test_verify_invalid_timestamp_format(self): + """Test signature verification with invalid timestamp format""" + payload = "test payload" + invalid_timestamp = "not-a-number" + signature = generate_signature(payload, invalid_timestamp) + + is_valid = verify_webflow_signature( + payload.encode('utf-8'), + signature, + invalid_timestamp, + webhook_secret + ) + + assert is_valid is False + + +@pytest.mark.parametrize("webhook_secret_env", ["", None]) +def test_missing_webhook_secret(webhook_secret_env, monkeypatch): + """Test webhook handler when WEBFLOW_WEBHOOK_SECRET is not set""" + if webhook_secret_env is None: + monkeypatch.delenv("WEBFLOW_WEBHOOK_SECRET", raising=False) + else: + monkeypatch.setenv("WEBFLOW_WEBHOOK_SECRET", webhook_secret_env) + + # Reload the app to pick up the environment change + from importlib import reload + import main + reload(main) + + test_client = TestClient(main.app) + + payload = {"triggerType": "test", "payload": {}} + timestamp = str(int(time.time() * 1000)) + # Generate signature with the test secret (not the empty one) + signature = generate_signature(json.dumps(payload), timestamp, "test_secret") + + response = test_client.post( + "/webhooks/webflow", + headers={ + "x-webflow-signature": signature, + "x-webflow-timestamp": timestamp, + "content-type": "application/json" + }, + json=payload + ) + + assert response.status_code == 500 + assert response.json()["detail"] == "Webhook secret not configured" \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/nextjs/.env.example b/skills/webflow-webhooks/examples/nextjs/.env.example new file mode 100644 index 0000000..537082b --- /dev/null +++ b/skills/webflow-webhooks/examples/nextjs/.env.example @@ -0,0 +1,7 @@ +# For webhooks created via OAuth App +# Use your OAuth app's client secret +WEBFLOW_WEBHOOK_SECRET=your_oauth_client_secret + +# For webhooks created via API (after April 2025) +# Use the webhook-specific secret returned when creating the webhook +# Format: whsec_xxxxxxxxxxxxx \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/nextjs/README.md b/skills/webflow-webhooks/examples/nextjs/README.md new file mode 100644 index 0000000..a82c6a4 --- /dev/null +++ b/skills/webflow-webhooks/examples/nextjs/README.md @@ -0,0 +1,110 @@ +# Webflow Webhooks - Next.js Example + +Minimal example of receiving Webflow webhooks with signature verification using Next.js App Router. + +## Prerequisites + +- Node.js 18+ +- Webflow account with webhook configured +- Webhook signing secret (from OAuth app or API-created webhook) + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add your Webflow webhook signing secret to `.env`: + - For OAuth app webhooks: Use your OAuth client secret + - For API-created webhooks: Use the `secret` field from the creation response + +## Run + +```bash +# Development +npm run dev + +# Production +npm run build +npm start +``` + +Server runs on http://localhost:3000 + +Webhook endpoint: `POST http://localhost:3000/webhooks/webflow` + +## Test Locally + +Use Hookdeck CLI to create a public URL for your local server: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Create tunnel +hookdeck listen 3000 --path /webhooks/webflow +``` + +1. Copy the Hookdeck URL (e.g., `https://events.hookdeck.com/e/src_xxxxx`) +2. Add this URL as your webhook endpoint in Webflow +3. Trigger test events in Webflow +4. Check your Next.js console for logged events + +## Test Suite + +Run the test suite: + +```bash +npm test +``` + +Tests verify: +- Signature verification with valid signatures +- Rejection of invalid signatures +- Timestamp validation (5-minute window) +- Proper error handling +- Event type handling + +## Project Structure + +``` +├── app/ +│ └── webhooks/ +│ └── webflow/ +│ └── route.ts # Webhook handler using App Router +├── test/ +│ └── webhook.test.ts # Test suite +├── .env.example # Environment variables template +├── vitest.config.ts # Test configuration +├── package.json # Dependencies +└── README.md # This file +``` + +## Implementation Notes + +This example uses Next.js 16 App Router with: +- Route Handlers for the webhook endpoint +- Raw body parsing disabled for signature verification +- TypeScript for type safety +- Vitest for testing + +## Common Issues + +### Signature Verification Fails +- Ensure you're using the correct secret (OAuth client secret vs webhook-specific secret) +- Webhook must be created via API or OAuth app (not dashboard) for signatures +- Verify `bodyParser` is disabled in the route config + +### Missing Headers +- Dashboard-created webhooks don't include signature headers +- Recreate the webhook via API or OAuth app + +### Type Errors +- Ensure TypeScript is installed and configured +- Run `npm install` to get all type definitions \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/nextjs/app/webhooks/webflow/route.ts b/skills/webflow-webhooks/examples/nextjs/app/webhooks/webflow/route.ts new file mode 100644 index 0000000..196f103 --- /dev/null +++ b/skills/webflow-webhooks/examples/nextjs/app/webhooks/webflow/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; + +// Disable body parsing to get raw body for signature verification +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Verify Webflow webhook signature + */ +function verifyWebflowSignature( + rawBody: string, + signature: string, + timestamp: string, + secret: string +): boolean { + // Validate timestamp to prevent replay attacks (5-minute window) + const currentTime = Date.now(); + const webhookTime = parseInt(timestamp); + + if (isNaN(webhookTime)) { + return false; + } + + const timeDiff = Math.abs(currentTime - webhookTime); + if (timeDiff > 300000) { // 5 minutes = 300000 milliseconds + return false; + } + + // Create signed content: timestamp:body + const signedContent = `${timestamp}:${rawBody}`; + + // Generate expected signature + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(signedContent) + .digest('hex'); + + // Timing-safe comparison + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch (error) { + // Different lengths = invalid + return false; + } +} + +export async function POST(request: NextRequest) { + try { + // Get headers + const signature = request.headers.get('x-webflow-signature'); + const timestamp = request.headers.get('x-webflow-timestamp'); + + // Check required headers + if (!signature || !timestamp) { + console.error('Missing required headers'); + return NextResponse.json( + { error: 'Missing required headers' }, + { status: 400 } + ); + } + + // Get webhook secret + const secret = process.env.WEBFLOW_WEBHOOK_SECRET; + if (!secret) { + console.error('WEBFLOW_WEBHOOK_SECRET not configured'); + return NextResponse.json( + { error: 'Webhook secret not configured' }, + { status: 500 } + ); + } + + // Get raw body + const rawBody = await request.text(); + + // Verify signature + const isValid = verifyWebflowSignature(rawBody, signature, timestamp, secret); + + if (!isValid) { + console.error('Invalid webhook signature'); + return NextResponse.json( + { error: 'Invalid signature' }, + { status: 400 } + ); + } + + // Parse the verified payload + let event; + try { + event = JSON.parse(rawBody); + } catch (error) { + console.error('Failed to parse webhook body:', error); + return NextResponse.json( + { error: 'Invalid JSON' }, + { status: 400 } + ); + } + + // Log the event + console.log('Received Webflow webhook:', { + type: event.triggerType, + timestamp: new Date(parseInt(timestamp)).toISOString() + }); + + // Handle different event types + switch (event.triggerType) { + case 'form_submission': + console.log('Form submission:', { + formName: event.payload.name, + submittedAt: event.payload.submittedAt, + data: event.payload.data + }); + // Add your form submission handling logic here + break; + + case 'ecomm_new_order': + console.log('New order:', { + orderId: event.payload.orderId, + total: event.payload.total, + currency: event.payload.currency + }); + // Add your order processing logic here + break; + + case 'collection_item_created': + console.log('New CMS item:', { + id: event.payload._id, + name: event.payload.name, + collection: event.payload._cid + }); + // Add your CMS sync logic here + break; + + case 'collection_item_changed': + console.log('CMS item updated:', { + id: event.payload._id, + name: event.payload.name + }); + break; + + case 'collection_item_deleted': + console.log('CMS item deleted:', { + id: event.payload._id + }); + break; + + case 'site_publish': + console.log('Site published'); + // Add cache clearing or build trigger logic here + break; + + case 'user_account_added': + console.log('New user account:', { + userId: event.payload.userId + }); + break; + + default: + console.log('Unhandled event type:', event.triggerType); + } + + // Always return 200 to acknowledge receipt + return NextResponse.json({ received: true }, { status: 200 }); + + } catch (error) { + console.error('Unexpected error processing webhook:', error); + // Return 200 to prevent retries if we've already started processing + return NextResponse.json({ received: true }, { status: 200 }); + } +} + +// Export for testing +export { verifyWebflowSignature }; \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/nextjs/package.json b/skills/webflow-webhooks/examples/nextjs/package.json new file mode 100644 index 0000000..28e8b54 --- /dev/null +++ b/skills/webflow-webhooks/examples/nextjs/package.json @@ -0,0 +1,28 @@ +{ + "name": "webflow-webhooks-nextjs", + "version": "1.0.0", + "description": "Next.js webhook handler for Webflow", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run" + }, + "keywords": ["webflow", "webhooks", "nextjs"], + "author": "", + "license": "MIT", + "dependencies": { + "next": "^16.1.6", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18", + "@vitejs/plugin-react": "^4.3.0", + "dotenv": "^16.3.1" + } +} \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/nextjs/test/webhook.test.ts b/skills/webflow-webhooks/examples/nextjs/test/webhook.test.ts new file mode 100644 index 0000000..5e65312 --- /dev/null +++ b/skills/webflow-webhooks/examples/nextjs/test/webhook.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import crypto from 'crypto'; +import { POST, verifyWebflowSignature } from '../app/webhooks/webflow/route'; + +// Mock environment variables +vi.stubEnv('WEBFLOW_WEBHOOK_SECRET', 'test_webhook_secret_key'); + +describe('Webflow Webhook Handler', () => { + const webhookSecret = 'test_webhook_secret_key'; + + // Helper to generate valid signature + function generateSignature(payload: string, timestamp: string, secret = webhookSecret): string { + const signedContent = `${timestamp}:${payload}`; + return crypto + .createHmac('sha256', secret) + .update(signedContent) + .digest('hex'); + } + + // Helper to create test webhook request + function createWebhookRequest( + payload: string, + options: { + timestamp?: string; + signature?: string; + secret?: string; + headers?: Record; + } = {} + ): NextRequest { + const timestamp = options.timestamp || Date.now().toString(); + const signature = options.signature || generateSignature(payload, timestamp, options.secret); + + const headers = new Headers({ + 'x-webflow-signature': signature, + 'x-webflow-timestamp': timestamp, + 'content-type': 'application/json', + ...options.headers, + }); + + return new NextRequest('http://localhost:3000/webhooks/webflow', { + method: 'POST', + headers, + body: payload, + }); + } + + describe('POST /webhooks/webflow', () => { + it('should accept valid webhook with correct signature', async () => { + const payload = JSON.stringify({ + triggerType: 'form_submission', + payload: { + name: 'Contact Form', + siteId: '123456', + data: { + email: 'test@example.com', + message: 'Test message' + }, + submittedAt: '2024-01-15T12:00:00.000Z', + id: 'form123' + } + }); + + const request = createWebhookRequest(payload); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + it('should handle different event types', async () => { + const eventTypes = [ + { + triggerType: 'ecomm_new_order', + payload: { + orderId: 'order123', + total: 99.99, + currency: 'USD' + } + }, + { + triggerType: 'collection_item_created', + payload: { + _id: 'item123', + name: 'New Item', + _cid: 'collection123' + } + }, + { + triggerType: 'site_publish', + payload: {} + }, + { + triggerType: 'user_account_added', + payload: { + userId: 'user123' + } + } + ]; + + for (const event of eventTypes) { + const request = createWebhookRequest(JSON.stringify(event)); + const response = await POST(request); + expect(response.status).toBe(200); + } + }); + + it('should reject webhook with invalid signature', async () => { + const payload = JSON.stringify({ + triggerType: 'form_submission', + payload: { test: 'data' } + }); + + const request = createWebhookRequest(payload, { + signature: 'invalid_signature' + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid signature'); + }); + + it('should reject webhook with missing signature header', async () => { + const headers = new Headers({ + 'x-webflow-timestamp': Date.now().toString(), + 'content-type': 'application/json', + }); + + const request = new NextRequest('http://localhost:3000/webhooks/webflow', { + method: 'POST', + headers, + body: '{}', + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Missing required headers'); + }); + + it('should reject webhook with missing timestamp header', async () => { + const headers = new Headers({ + 'x-webflow-signature': 'some_signature', + 'content-type': 'application/json', + }); + + const request = new NextRequest('http://localhost:3000/webhooks/webflow', { + method: 'POST', + headers, + body: '{}', + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Missing required headers'); + }); + + it('should reject webhook with expired timestamp', async () => { + const payload = JSON.stringify({ triggerType: 'test', payload: {} }); + const oldTimestamp = (Date.now() - 400000).toString(); // 6+ minutes old (400000 ms) + + const request = createWebhookRequest(payload, { + timestamp: oldTimestamp + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid signature'); + }); + + it('should accept webhook with timestamp within 5-minute window', async () => { + const payload = JSON.stringify({ triggerType: 'test', payload: {} }); + const recentTimestamp = (Date.now() - 250000).toString(); // 4 minutes old (250000 ms) + + const request = createWebhookRequest(payload, { + timestamp: recentTimestamp + }); + const response = await POST(request); + + expect(response.status).toBe(200); + }); + + it('should reject webhook with wrong secret', async () => { + const payload = JSON.stringify({ triggerType: 'test', payload: {} }); + + const request = createWebhookRequest(payload, { + secret: 'wrong_secret' + }); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid signature'); + }); + + it('should handle invalid JSON gracefully', async () => { + const invalidJson = 'not valid json'; + const timestamp = Date.now().toString(); + const signature = generateSignature(invalidJson, timestamp); + + const headers = new Headers({ + 'x-webflow-signature': signature, + 'x-webflow-timestamp': timestamp, + 'content-type': 'application/json', + }); + + const request = new NextRequest('http://localhost:3000/webhooks/webflow', { + method: 'POST', + headers, + body: invalidJson, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid JSON'); + }); + + it('should return 500 when webhook secret is not configured', async () => { + // Temporarily clear the secret + vi.stubEnv('WEBFLOW_WEBHOOK_SECRET', ''); + + const payload = JSON.stringify({ triggerType: 'test', payload: {} }); + const request = createWebhookRequest(payload); + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('Webhook secret not configured'); + + // Restore the secret + vi.stubEnv('WEBFLOW_WEBHOOK_SECRET', webhookSecret); + }); + }); + + describe('verifyWebflowSignature', () => { + it('should verify valid signature', () => { + const payload = 'test payload'; + const timestamp = Date.now().toString(); + const signature = generateSignature(payload, timestamp); + + const isValid = verifyWebflowSignature( + payload, + signature, + timestamp, + webhookSecret + ); + + expect(isValid).toBe(true); + }); + + it('should reject invalid signature', () => { + const payload = 'test payload'; + const timestamp = Date.now().toString(); + + const isValid = verifyWebflowSignature( + payload, + 'invalid_signature', + timestamp, + webhookSecret + ); + + expect(isValid).toBe(false); + }); + + it('should reject expired timestamp', () => { + const payload = 'test payload'; + const oldTimestamp = (Date.now() - 400000).toString(); + const signature = generateSignature(payload, oldTimestamp); + + const isValid = verifyWebflowSignature( + payload, + signature, + oldTimestamp, + webhookSecret + ); + + expect(isValid).toBe(false); + }); + + it('should handle invalid timestamp format', () => { + const payload = 'test payload'; + const signature = generateSignature(payload, 'not-a-number'); + + const isValid = verifyWebflowSignature( + payload, + signature, + 'not-a-number', + webhookSecret + ); + + expect(isValid).toBe(false); + }); + + it('should handle signatures of different lengths', () => { + const payload = 'test payload'; + const timestamp = Date.now().toString(); + const validSignature = generateSignature(payload, timestamp); + const shortSignature = validSignature.substring(0, 10); + + const isValid = verifyWebflowSignature( + payload, + shortSignature, + timestamp, + webhookSecret + ); + + expect(isValid).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/skills/webflow-webhooks/examples/nextjs/vitest.config.ts b/skills/webflow-webhooks/examples/nextjs/vitest.config.ts new file mode 100644 index 0000000..71c9599 --- /dev/null +++ b/skills/webflow-webhooks/examples/nextjs/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'node', + globals: true, + }, +}); \ No newline at end of file diff --git a/skills/webflow-webhooks/references/overview.md b/skills/webflow-webhooks/references/overview.md new file mode 100644 index 0000000..ab151b6 --- /dev/null +++ b/skills/webflow-webhooks/references/overview.md @@ -0,0 +1,124 @@ +# Webflow Webhooks Overview + +## What Are Webflow Webhooks? + +Webflow webhooks are HTTP callbacks that notify your application when events occur in a Webflow site. They enable real-time integration with external systems, allowing you to react to form submissions, content changes, ecommerce orders, and site publishing events. + +## Common Event Types + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `form_submission` | A form is submitted on your site | Lead capture, email notifications, CRM integration | +| `site_publish` | Site is published | Clear CDN caches, trigger static site builds, notify team | +| `page_created` | New page created | Content auditing, SEO tools integration | +| `page_metadata_updated` | Page metadata changes | Update sitemap, SEO monitoring | +| `page_deleted` | Page is deleted | Remove from external indexes, cleanup | +| `ecomm_new_order` | New ecommerce order placed | Order processing, inventory management, fulfillment | +| `ecomm_order_changed` | Order status/details change | Update shipping, customer notifications | +| `ecomm_inventory_changed` | Product inventory changes | Sync with external inventory systems | +| `user_account_added` | New user account created | Welcome emails, user provisioning | +| `user_account_updated` | User account details change | Sync user data, audit trail | +| `user_account_deleted` | User account deleted | Cleanup, GDPR compliance | +| `collection_item_created` | CMS item created | Content syndication, search indexing | +| `collection_item_changed` | CMS item updated | Update external systems, clear caches | +| `collection_item_deleted` | CMS item deleted | Remove from external systems | +| `collection_item_unpublished` | CMS item unpublished | Update content visibility | + +## Event Payload Structure + +All Webflow webhook events follow a consistent structure: + +```json +{ + "triggerType": "event_name", + "payload": { + // Event-specific data + } +} +``` + +### Example: Form Submission Event + +```json +{ + "triggerType": "form_submission", + "payload": { + "name": "Contact Us Form", + "siteId": "65427cf400e02b306eaa049c", + "data": { + "First Name": "John", + "Last Name": "Doe", + "email": "john.doe@example.com", + "message": "I'd like to learn more about your services" + }, + "submittedAt": "2024-01-15T14:30:00.000Z", + "id": "65a5d3c8f7e2b40012345678" + } +} +``` + +### Example: Ecommerce Order Event + +```json +{ + "triggerType": "ecomm_new_order", + "payload": { + "orderId": "65a5d4e9f7e2b40012345679", + "status": "pending", + "customerId": "65a5d4e9f7e2b40012345680", + "total": 149.99, + "currency": "USD", + "items": [ + { + "productId": "65a5d4e9f7e2b40012345681", + "name": "Premium Widget", + "quantity": 2, + "price": 74.99 + } + ], + "createdOn": "2024-01-15T14:35:00.000Z" + } +} +``` + +### Example: CMS Collection Item Event + +```json +{ + "triggerType": "collection_item_created", + "payload": { + "_id": "65a5d5f2f7e2b40012345682", + "name": "New Blog Post Title", + "slug": "new-blog-post-title", + "_cid": "65a5d5f2f7e2b40012345683", + "_draft": false, + "fields": { + "title": "New Blog Post Title", + "content": "Post content here...", + "author": "Jane Smith", + "publishDate": "2024-01-15T00:00:00.000Z" + } + } +} +``` + +## Webhook Limits + +| Criteria | Limit | +|----------|-------| +| Max webhooks per trigger type | 75 | +| Retry attempts on failure | 3 | +| Retry interval | 10 minutes | +| Request timeout | 30 seconds | +| Max payload size | 256 KB | + +## Security Considerations + +- **Signature Verification**: Webhooks created via OAuth apps or API include signature headers for verification +- **HTTPS Only**: Webhook endpoints must use HTTPS in production +- **Timestamp Validation**: Check timestamps to prevent replay attacks (5-minute window) +- **Raw Body**: Always use the raw request body for signature verification + +## Full Event Reference + +For the complete list of events and their payload structures, see [Webflow's webhook documentation](https://developers.webflow.com/data/docs/working-with-webhooks). \ No newline at end of file diff --git a/skills/webflow-webhooks/references/setup.md b/skills/webflow-webhooks/references/setup.md new file mode 100644 index 0000000..3d73fff --- /dev/null +++ b/skills/webflow-webhooks/references/setup.md @@ -0,0 +1,172 @@ +# Setting Up Webflow Webhooks + +## Prerequisites + +- A Webflow account with an active site +- For signature verification: OAuth app or API access +- Your application's webhook endpoint URL (must be HTTPS in production) + +## Two Ways to Create Webhooks + +### 1. Via Webflow Dashboard (No Signature Verification) + +⚠️ **Note**: Webhooks created through the dashboard do NOT include signature headers for verification. + +1. Go to your Webflow project +2. Navigate to **Project Settings** → **Integrations** → **Webhooks** +3. Click **Add Webhook** +4. Select the trigger event +5. Enter your endpoint URL +6. Save the webhook + +### 2. Via API (Recommended - Includes Signatures) + +This method provides signature headers for secure verification. + +#### Get Your API Credentials + +**For OAuth Apps:** +1. Go to [Webflow Dashboard](https://webflow.com/dashboard/account/apps) +2. Create or select your app +3. Note your **Client ID** and **Client Secret** +4. The Client Secret will be your webhook signing secret + +**For Site API Tokens:** +1. Go to **Project Settings** → **Integrations** → **API Access** +2. Generate a site token +3. For webhooks created after April 2025, you'll receive a webhook-specific secret + +#### Create Webhook via API + +```bash +curl -X POST https://api.webflow.com/sites/{site_id}/webhooks \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "triggerType": "form_submission", + "url": "https://your-app.com/webhooks/webflow", + "filter": { + "name": "contact-form" + } + }' +``` + +Response (for webhooks after April 2025): +```json +{ + "id": "65a5d7a8f7e2b40012345684", + "triggerType": "form_submission", + "url": "https://your-app.com/webhooks/webflow", + "secret": "whsec_1234567890abcdef", + "createdOn": "2024-01-15T14:45:00.000Z" +} +``` + +Save the `secret` field - this is your webhook signing secret. + +## Required Scopes + +Different webhook types require different API scopes: + +| Webhook Type | Required Scope | +|--------------|----------------| +| `form_submission` | `forms:read` | +| `ecomm_*` events | `ecommerce:read` | +| `collection_item_*` | `cms:read` | +| `page_*` events | `pages:read` | +| `site_publish` | `sites:read` | +| `user_account_*` | `users:read` | + +## Managing Webhooks + +### List Existing Webhooks + +```bash +curl -X GET https://api.webflow.com/sites/{site_id}/webhooks \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### Update a Webhook + +```bash +curl -X PATCH https://api.webflow.com/webhooks/{webhook_id} \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://your-new-endpoint.com/webhooks/webflow" + }' +``` + +### Delete a Webhook + +```bash +curl -X DELETE https://api.webflow.com/webhooks/{webhook_id} \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +## Testing Your Webhook + +### 1. Local Development with Hookdeck + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Create a tunnel to your local server +hookdeck listen 3000 --path /webhooks/webflow +``` + +### 2. Test Events + +**Form Submission Test:** +1. Create a test form on your Webflow site +2. Publish the site +3. Submit the form +4. Check your webhook endpoint logs + +**CMS Event Test:** +1. Create or update a CMS item +2. Publish the changes +3. Verify webhook delivery + +## Environment Setup + +Create a `.env` file for your application: + +```bash +# For OAuth App webhooks +WEBFLOW_WEBHOOK_SECRET=your_oauth_client_secret + +# For API-created webhooks (after April 2025) +WEBFLOW_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx + +# Optional: For making API calls +WEBFLOW_API_TOKEN=your_api_token +WEBFLOW_SITE_ID=your_site_id +``` + +## Troubleshooting + +### Webhook Not Firing +- Ensure the site is published (draft changes don't trigger webhooks) +- Check the webhook is enabled in settings +- Verify your endpoint returns a 200 status + +### Signature Verification Failing +- Confirm you're using the raw request body (not parsed JSON) +- Check you're using the correct secret (OAuth client secret vs webhook secret) +- Verify headers are lowercase in your framework (some normalize to lowercase) +- Ensure timestamp is within 5-minute window + +### Missing Headers +- Dashboard-created webhooks don't include signature headers +- Only OAuth app or API-created webhooks have `x-webflow-signature` and `x-webflow-timestamp` + +## Best Practices + +1. **Always verify signatures** for webhooks that include them +2. **Validate timestamps** to prevent replay attacks +3. **Return 200 quickly** to avoid timeouts (process async if needed) +4. **Log raw payloads** during development for debugging +5. **Use HTTPS** for production endpoints +6. **Handle retries** - Webflow retries failed webhooks up to 3 times \ No newline at end of file diff --git a/skills/webflow-webhooks/references/verification.md b/skills/webflow-webhooks/references/verification.md new file mode 100644 index 0000000..af26da9 --- /dev/null +++ b/skills/webflow-webhooks/references/verification.md @@ -0,0 +1,259 @@ +# Webflow Signature Verification + +## How It Works + +Webflow uses HMAC-SHA256 to sign webhook payloads. The signature allows you to verify that webhooks are sent by Webflow and haven't been tampered with. + +The signing process: +1. Concatenates the timestamp and raw request body with a colon: `timestamp:body` +2. Generates an HMAC-SHA256 hash using your secret key +3. Includes the hash in the `x-webflow-signature` header + +## Headers + +Signed webhooks include two headers: + +- **`x-webflow-timestamp`**: Unix epoch timestamp in milliseconds when the webhook was sent +- **`x-webflow-signature`**: HMAC-SHA256 hash of the signed content + +## Implementation + +### Node.js/JavaScript + +```javascript +const crypto = require('crypto'); + +function verifyWebflowSignature(rawBody, signature, timestamp, secret) { + // Step 1: Validate timestamp (5-minute window) + const currentTime = Date.now(); + const webhookTime = parseInt(timestamp); + + if (isNaN(webhookTime)) { + return false; + } + + const timeDiff = Math.abs(currentTime - webhookTime); + if (timeDiff > 300000) { // 5 minutes = 300000 milliseconds + return false; + } + + // Step 2: Create the signed content + const signedContent = `${timestamp}:${rawBody}`; + + // Step 3: Generate expected signature + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(signedContent) + .digest('hex'); + + // Step 4: Timing-safe comparison + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch (error) { + // Buffers of different lengths throw an error + return false; + } +} + +// Express middleware example +function webflowWebhookMiddleware(secret) { + return (req, res, next) => { + const signature = req.headers['x-webflow-signature']; + const timestamp = req.headers['x-webflow-timestamp']; + + if (!signature || !timestamp) { + return res.status(400).send('Missing required headers'); + } + + const isValid = verifyWebflowSignature( + req.body.toString(), + signature, + timestamp, + secret + ); + + if (!isValid) { + return res.status(400).send('Invalid signature'); + } + + // Parse the validated body + req.body = JSON.parse(req.body); + next(); + }; +} +``` + +### Python + +```python +import hmac +import hashlib +import time + +def verify_webflow_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool: + """Verify Webflow webhook signature""" + + # Step 1: Validate timestamp (5-minute window) + try: + webhook_time = int(timestamp) + except ValueError: + return False + + current_time = int(time.time() * 1000) + time_diff = abs(current_time - webhook_time) + + if time_diff > 300000: # 5 minutes = 300000 milliseconds + return False + + # Step 2: Create signed content + signed_content = f"{timestamp}:{raw_body.decode('utf-8')}" + + # Step 3: Generate expected signature + expected_signature = hmac.new( + secret.encode('utf-8'), + signed_content.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + # Step 4: Timing-safe comparison + return hmac.compare_digest(signature, expected_signature) +``` + +## Common Gotchas + +### 1. Raw Body Parsing + +**Problem**: Many frameworks automatically parse JSON bodies, but signature verification requires the raw body string. + +**Solution**: Configure your framework to provide raw body access. + +Express: +```javascript +app.post('/webhooks/webflow', + express.raw({ type: 'application/json' }), // Get raw body + webflowWebhookMiddleware(secret), + handler +); +``` + +Next.js: +```javascript +export const config = { + api: { + bodyParser: false, // Disable automatic parsing + }, +}; +``` + +FastAPI: +```python +from fastapi import Request + +@app.post("/webhooks/webflow") +async def webhook(request: Request): + raw_body = await request.body() # Get raw bytes +``` + +### 2. Header Name Casing + +**Problem**: Some frameworks normalize header names to lowercase. + +**Solution**: Always use lowercase when accessing headers. + +```javascript +// Good - works everywhere +const signature = req.headers['x-webflow-signature']; + +// Bad - might fail in some frameworks +const signature = req.headers['X-Webflow-Signature']; +``` + +### 3. Secret Key Confusion + +**Problem**: Different webhook creation methods use different secrets. + +**Solution**: Know which secret to use: + +- **OAuth App Webhooks**: Use your OAuth app's client secret +- **API-created Webhooks (after April 2025)**: Use the webhook-specific secret returned in the creation response +- **Dashboard Webhooks**: No signature verification available + +### 4. Encoding Mismatch + +**Problem**: Signature comparison fails due to encoding differences. + +**Solution**: Ensure consistent encoding (Webflow uses hex encoding): + +```javascript +// Correct - hex encoding +.digest('hex'); + +// Wrong - base64 encoding +.digest('base64'); +``` + +## Debugging Verification Failures + +### 1. Log Everything During Development + +```javascript +function debugVerification(rawBody, signature, timestamp, secret) { + console.log('Raw Body:', rawBody); + console.log('Signature Header:', signature); + console.log('Timestamp Header:', timestamp); + console.log('Secret (first 6 chars):', secret.substring(0, 6) + '...'); + + const signedContent = `${timestamp}:${rawBody}`; + console.log('Signed Content:', signedContent); + + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(signedContent) + .digest('hex'); + console.log('Expected Signature:', expectedSignature); + + console.log('Signatures Match:', signature === expectedSignature); +} +``` + +### 2. Common Error Messages + +| Error | Likely Cause | Solution | +|-------|--------------|----------| +| "Missing required headers" | No signature headers | Webhook created via dashboard - recreate via API | +| "Invalid signature" | Wrong secret | Check OAuth client secret vs webhook secret | +| "Invalid signature" | Body parsing | Ensure using raw body, not parsed JSON | +| "Invalid signature" | Timestamp expired | Check server time, allow 5-minute window | +| "Invalid signature" | Encoding issue | Verify using hex encoding | + +### 3. Test Signature Generation + +Create a test to verify your implementation: + +```javascript +const testPayload = '{"triggerType":"form_submission","payload":{}}'; +const testTimestamp = '1705332000'; +const testSecret = 'test_secret'; + +// Expected signature for these inputs +const expectedSignature = crypto + .createHmac('sha256', testSecret) + .update(`${testTimestamp}:${testPayload}`) + .digest('hex'); + +console.log('Test signature:', expectedSignature); +// Should output: 3f8e5d6c6a1b8f7d4e2a9c5b1d7e3f9a2c4e6b8d0f3a5c7e9b1d3f5a7c9e0b2d +``` + +## Security Best Practices + +1. **Always verify signatures** when available +2. **Validate timestamps** to prevent replay attacks +3. **Use timing-safe comparison** to prevent timing attacks +4. **Keep secrets secure** - use environment variables, never commit to code +5. **Log failures** for security monitoring +6. **Fail closed** - reject requests with invalid signatures +7. **Use HTTPS** to prevent man-in-the-middle attacks \ No newline at end of file