diff --git a/README.md b/README.md index 7942d3d..8ac609f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | SendGrid | [`sendgrid-webhooks`](skills/sendgrid-webhooks/) | Verify SendGrid webhook signatures (ECDSA), handle email delivery events | | 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 | ### Webhook Handler Pattern Skills diff --git a/providers.yaml b/providers.yaml index dd6f532..3c61032 100644 --- a/providers.yaml +++ b/providers.yaml @@ -206,6 +206,20 @@ providers: - payment_intent.succeeded - checkout.session.completed + - name: vercel + displayName: Vercel + docs: + webhooks: https://vercel.com/docs/observability/webhooks + events: https://vercel.com/docs/observability/webhooks#event-types + notes: > + Platform for deploying web applications. Uses x-vercel-signature header with HMAC-SHA1 + (hex encoded). Uses raw request body for signature verification. + Common events: deployment.created, deployment.succeeded, project.created. + testScenario: + events: + - deployment.created + - deployment.succeeded + - name: hookdeck-event-gateway displayName: Hookdeck Event Gateway docs: diff --git a/scripts/validate-provider.sh b/scripts/validate-provider.sh index fd74d43..1892b9b 100755 --- a/scripts/validate-provider.sh +++ b/scripts/validate-provider.sh @@ -212,20 +212,28 @@ validate_integration() { errors+=("$provider not found in README.md Provider Skills table") fi - # Check providers.yaml has entry + # Check providers.yaml has entry with testScenario + # test-agent-scenario.sh now reads scenarios dynamically from providers.yaml if [ -f "$ROOT_DIR/providers.yaml" ]; then if ! grep -q "name: $provider_name" "$ROOT_DIR/providers.yaml"; then errors+=("$provider_name not found in providers.yaml") + else + # Check that the provider has a testScenario defined + # Use awk to find the provider block and check for testScenario + local has_test_scenario + has_test_scenario=$(awk -v provider="$provider_name" ' + /^ - name:/ { in_provider = ($3 == provider) } + in_provider && /testScenario:/ { print "yes"; exit } + /^ - name:/ && !($3 == provider) { in_provider = 0 } + ' "$ROOT_DIR/providers.yaml") + if [ "$has_test_scenario" != "yes" ]; then + errors+=("No testScenario for $provider_name in providers.yaml") + fi fi else errors+=("providers.yaml not found at repository root") fi - # Check test-agent-scenario.sh has at least one scenario - if ! grep -q "$provider_name" "$ROOT_DIR/scripts/test-agent-scenario.sh"; then - errors+=("No scenario for $provider_name in scripts/test-agent-scenario.sh") - fi - # Return errors if [ ${#errors[@]} -gt 0 ]; then printf '%s\n' "${errors[@]}" @@ -324,8 +332,7 @@ if [ ${#FAILED_PROVIDERS[@]} -gt 0 ]; then log "Please ensure you have updated:" log " 1. All required skill files (SKILL.md, references/, examples/)" log " 2. README.md - Add provider to Provider Skills table" - log " 3. providers.yaml - Add provider entry with documentation URLs" - log " 4. scripts/test-agent-scenario.sh - Add at least one test scenario" + log " 3. providers.yaml - Add provider entry with documentation URLs and testScenario" exit 1 else log "${GREEN}All ${#PASSED_PROVIDERS[@]} provider(s) passed validation!${NC}" diff --git a/skills/vercel-webhooks/SKILL.md b/skills/vercel-webhooks/SKILL.md new file mode 100644 index 0000000..fc66e8f --- /dev/null +++ b/skills/vercel-webhooks/SKILL.md @@ -0,0 +1,210 @@ +--- +name: vercel-webhooks +description: > + Receive and verify Vercel webhooks. Use when setting up Vercel webhook + handlers, debugging signature verification, or handling deployment events + like deployment.created, deployment.succeeded, or project.created. +license: MIT +metadata: + author: hookdeck + version: "0.1.0" + repository: https://github.com/hookdeck/webhook-skills +--- + +# Vercel Webhooks + +## When to Use This Skill + +- Setting up Vercel webhook handlers +- Debugging signature verification failures +- Understanding Vercel event types and payloads +- Handling deployment, project, domain, or integration events +- Monitoring deployment status changes + +## Essential Code (USE THIS) + +### Express Webhook Handler with Manual Verification + +```javascript +const express = require('express'); +const crypto = require('crypto'); + +const app = express(); + +// CRITICAL: Use express.raw() for webhook endpoint - Vercel needs raw body +app.post('/webhooks/vercel', + express.raw({ type: 'application/json' }), + async (req, res) => { + const signature = req.headers['x-vercel-signature']; + + if (!signature) { + return res.status(400).send('Missing x-vercel-signature header'); + } + + // Verify signature using SHA1 HMAC + const expectedSignature = crypto + .createHmac('sha1', process.env.VERCEL_WEBHOOK_SECRET) + .update(req.body) + .digest('hex'); + + // Use timing-safe comparison + let signaturesMatch; + try { + signaturesMatch = crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch (err) { + // Buffer length mismatch = invalid signature + signaturesMatch = false; + } + + if (!signaturesMatch) { + console.error('Invalid Vercel webhook signature'); + return res.status(400).send('Invalid signature'); + } + + // Parse the verified payload + const event = JSON.parse(req.body.toString()); + + // Handle the event + switch (event.type) { + case 'deployment.created': + console.log('Deployment created:', event.payload.deployment.id); + break; + case 'deployment.succeeded': + console.log('Deployment succeeded:', event.payload.deployment.id); + break; + case 'deployment.error': + console.log('Deployment failed:', event.payload.deployment.id); + break; + case 'project.created': + console.log('Project created:', event.payload.project.name); + break; + default: + console.log('Unhandled event:', event.type); + } + + res.json({ received: true }); + } +); +``` + +### Python (FastAPI) Webhook Handler + +```python +import os +import hmac +import hashlib +from fastapi import FastAPI, Request, HTTPException, Header + +app = FastAPI() +webhook_secret = os.environ.get("VERCEL_WEBHOOK_SECRET") + +@app.post("/webhooks/vercel") +async def vercel_webhook( + request: Request, + x_vercel_signature: str = Header(None) +): + if not x_vercel_signature: + raise HTTPException(status_code=400, detail="Missing x-vercel-signature header") + + # Get raw body + body = await request.body() + + # Compute expected signature + expected_signature = hmac.new( + webhook_secret.encode(), + body, + hashlib.sha1 + ).hexdigest() + + # Timing-safe comparison + if not hmac.compare_digest(x_vercel_signature, expected_signature): + raise HTTPException(status_code=400, detail="Invalid signature") + + # Parse verified payload + event = await request.json() + + # Handle event + if event["type"] == "deployment.created": + print(f"Deployment created: {event['payload']['deployment']['id']}") + elif event["type"] == "deployment.succeeded": + print(f"Deployment succeeded: {event['payload']['deployment']['id']}") + # ... handle other events + + return {"received": True} +``` + +> **For complete working examples with tests**, see: +> - [examples/express/](examples/express/) - Full Express implementation +> - [examples/nextjs/](examples/nextjs/) - Next.js App Router implementation +> - [examples/fastapi/](examples/fastapi/) - Python FastAPI implementation + +## Common Event Types + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `deployment.created` | A new deployment starts | Start deployment monitoring, notify team | +| `deployment.succeeded` | Deployment completes successfully | Update status, trigger post-deploy tasks | +| `deployment.error` | Deployment fails | Alert team, rollback actions | +| `deployment.canceled` | Deployment is canceled | Clean up resources | +| `project.created` | New project is created | Set up monitoring, configure resources | +| `project.removed` | Project is deleted | Clean up external resources | +| `domain.created` | Domain is added | Update DNS, SSL configuration | + +See [references/overview.md](references/overview.md) for the complete event list. + +## Environment Variables + +```bash +# Required +VERCEL_WEBHOOK_SECRET=your_webhook_secret_from_dashboard + +# Optional (for API calls) +VERCEL_TOKEN=your_vercel_api_token +``` + +## 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/vercel +``` + +No account required. Provides local tunnel + web UI for inspecting requests. + +## Reference Materials + +- [Webhook Overview](references/overview.md) - What Vercel webhooks are, all event types +- [Setup Guide](references/setup.md) - Configure webhooks in Vercel dashboard +- [Signature Verification](references/verification.md) - SHA1 HMAC verification details + +## Recommended: webhook-handler-patterns + +For production-ready webhook handling, also install the `webhook-handler-patterns` skill to learn: + +- [Handler sequence](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/handler-sequence.md) - Correct order of operations +- [Idempotency](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/idempotency.md) - Handle duplicate webhooks +- [Error handling](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/error-handling.md) - Proper status codes and retries +- [Retry logic](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/retry-logic.md) - Handle transient failures + +## Related Skills + +- [stripe-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/stripe-webhooks) - Stripe payment webhooks +- [github-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/github-webhooks) - GitHub repository webhooks +- [shopify-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/shopify-webhooks) - Shopify store webhooks +- [sendgrid-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/sendgrid-webhooks) - SendGrid email webhooks +- [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) - Idempotency, error handling, retry logic +- [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) - Production webhook infrastructure \ No newline at end of file diff --git a/skills/vercel-webhooks/TODO.md b/skills/vercel-webhooks/TODO.md new file mode 100644 index 0000000..ec85f0c --- /dev/null +++ b/skills/vercel-webhooks/TODO.md @@ -0,0 +1,27 @@ +# TODO - Known Issues and Improvements + +*Last updated: 2026-02-04* + +These items were identified during automated review but are acceptable for merge. +Contributions to address these items are welcome. + +## Issues + +### Major + +- [ ] **skills/vercel-webhooks/references/overview.md**: The 'attack.detected' event is included but marked as potentially requiring specific plans or security features. Without official documentation confirmation, this could mislead users + - Suggested fix: Either verify this event exists in Vercel's documentation or remove it entirely to avoid confusion + +### Minor + +- [ ] **skills/vercel-webhooks/examples/express/package.json**: Using Express 5.x (^5.2.1) which is newer. Most production apps still use Express 4.x + - Suggested fix: Consider using Express ^4.21.2 for broader compatibility, or add a note about Express 5.x being used +- [ ] **skills/vercel-webhooks/examples/nextjs/package.json**: Next.js version 16.1.6 doesn't exist. The latest stable version is around 14.x or 15.x + - Suggested fix: Use a valid Next.js version like ^14.2.0 or ^15.0.0 + +## Suggestions + +- [ ] Consider adding rate limiting recommendations in the documentation for production deployments +- [ ] The webhook security documentation could mention that the signature verification details are based on common patterns, as Vercel's official docs don't explicitly detail the verification method +- [ ] Consider adding a note about webhook retry behavior and idempotency handling best practices + diff --git a/skills/vercel-webhooks/examples/express/.env.example b/skills/vercel-webhooks/examples/express/.env.example new file mode 100644 index 0000000..1a1278d --- /dev/null +++ b/skills/vercel-webhooks/examples/express/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and add your Vercel webhook secret +VERCEL_WEBHOOK_SECRET=your_webhook_secret_from_vercel_dashboard + +# Server port +PORT=3000 \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/express/README.md b/skills/vercel-webhooks/examples/express/README.md new file mode 100644 index 0000000..fde5500 --- /dev/null +++ b/skills/vercel-webhooks/examples/express/README.md @@ -0,0 +1,101 @@ +# Vercel Webhooks - Express Example + +Minimal example of receiving Vercel webhooks with signature verification using Express. + +## Prerequisites + +- Node.js 18+ +- Vercel account with Pro or Enterprise plan +- Webhook secret from Vercel dashboard + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add your Vercel webhook secret to `.env`: + ``` + VERCEL_WEBHOOK_SECRET=your_secret_from_vercel_dashboard + ``` + +## Run + +Start the server: +```bash +npm start +``` + +Server runs on http://localhost:3000 + +The webhook endpoint is available at: +``` +POST http://localhost:3000/webhooks/vercel +``` + +## Test + +Run the test suite: +```bash +npm test +``` + +## Local Testing with Hookdeck + +For local webhook development, use the Hookdeck CLI: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Start local tunnel +hookdeck listen 3000 --path /webhooks/vercel + +# Copy the webhook URL and add it to your Vercel dashboard +``` + +## Project Structure + +``` +. +├── src/ +│ └── index.js # Express server with webhook handler +├── test/ +│ └── webhook.test.js # Tests with signature verification +├── .env.example +├── package.json +└── README.md +``` + +## Triggering Test Events + +To trigger a test webhook from Vercel: + +1. Make a small change to your project +2. Deploy with: `vercel --force` +3. This will trigger a `deployment.created` event + +## Common Issues + +### Signature Verification Failing + +- Ensure you're using the raw request body +- Check that the secret in `.env` matches exactly (no extra spaces) +- Verify the header name is lowercase: `x-vercel-signature` + +### Missing Headers + +- Vercel sends the signature in `x-vercel-signature` +- Express converts headers to lowercase + +### Webhook Not Received + +- Verify your endpoint is publicly accessible +- Check Vercel dashboard for delivery status +- Ensure you've selected the correct events to receive \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/express/package.json b/skills/vercel-webhooks/examples/express/package.json new file mode 100644 index 0000000..381cfea --- /dev/null +++ b/skills/vercel-webhooks/examples/express/package.json @@ -0,0 +1,23 @@ +{ + "name": "vercel-webhooks-express", + "version": "1.0.0", + "description": "Express example for handling Vercel webhooks", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "test": "jest --forceExit" + }, + "dependencies": { + "express": "^5.2.1", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "jest": "^30.2.0", + "supertest": "^6.3.3", + "nodemon": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/express/src/index.js b/skills/vercel-webhooks/examples/express/src/index.js new file mode 100644 index 0000000..b0d5ab8 --- /dev/null +++ b/skills/vercel-webhooks/examples/express/src/index.js @@ -0,0 +1,191 @@ +require('dotenv').config(); +const express = require('express'); +const crypto = require('crypto'); + +const app = express(); +const port = process.env.PORT || 3000; + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +// Vercel webhook endpoint +// CRITICAL: Use express.raw() to get the raw body for signature verification +app.post('/webhooks/vercel', + express.raw({ type: 'application/json' }), + async (req, res) => { + const signature = req.headers['x-vercel-signature']; + + if (!signature) { + console.error('Missing x-vercel-signature header'); + return res.status(400).json({ error: 'Missing x-vercel-signature header' }); + } + + // Verify the webhook signature + const secret = process.env.VERCEL_WEBHOOK_SECRET; + if (!secret) { + console.error('VERCEL_WEBHOOK_SECRET not configured'); + return res.status(500).json({ error: 'Webhook secret not configured' }); + } + + // Compute expected signature using SHA1 + const expectedSignature = crypto + .createHmac('sha1', secret) + .update(req.body) + .digest('hex'); + + // Use timing-safe comparison + let signaturesMatch; + try { + signaturesMatch = crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch (err) { + // Buffer lengths don't match = invalid signature + signaturesMatch = false; + } + + if (!signaturesMatch) { + console.error('Invalid webhook signature'); + return res.status(400).json({ error: 'Invalid signature' }); + } + + // Parse the verified webhook payload + let event; + try { + event = JSON.parse(req.body.toString()); + } catch (err) { + console.error('Invalid JSON payload:', err); + return res.status(400).json({ error: 'Invalid JSON payload' }); + } + + // Log the event + console.log(`Received Vercel webhook: ${event.type}`); + console.log('Event ID:', event.id); + console.log('Created at:', new Date(event.createdAt).toISOString()); + + // Handle different event types + try { + switch (event.type) { + case 'deployment.created': + console.log('Deployment created:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + url: event.payload.deployment?.url, + project: event.payload.project?.name, + team: event.payload.team?.name + }); + break; + + case 'deployment.succeeded': + console.log('Deployment succeeded:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + url: event.payload.deployment?.url, + duration: event.payload.deployment?.duration + }); + break; + + case 'deployment.ready': + console.log('Deployment ready:', { + id: event.payload.deployment?.id, + url: event.payload.deployment?.url + }); + break; + + case 'deployment.error': + console.error('Deployment failed:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + error: event.payload.deployment?.error + }); + break; + + case 'deployment.canceled': + console.log('Deployment canceled:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name + }); + break; + + case 'deployment.promoted': + console.log('Deployment promoted:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + url: event.payload.deployment?.url, + target: event.payload.deployment?.target + }); + break; + + case 'project.created': + console.log('Project created:', { + id: event.payload.project?.id, + name: event.payload.project?.name + }); + break; + + case 'project.removed': + console.log('Project removed:', { + id: event.payload.project?.id, + name: event.payload.project?.name + }); + break; + + case 'project.renamed': + console.log('Project renamed:', { + id: event.payload.project?.id, + oldName: event.payload.project?.oldName, + newName: event.payload.project?.name + }); + break; + + case 'domain.created': + console.log('Domain created:', { + domain: event.payload.domain?.name, + project: event.payload.project?.name + }); + break; + + case 'integration-configuration.removed': + console.log('Integration removed:', { + id: event.payload.configuration?.id, + integration: event.payload.integration?.name + }); + break; + + default: + console.log('Unhandled event type:', event.type); + console.log('Payload:', JSON.stringify(event.payload, null, 2)); + } + + // Return success response + res.status(200).json({ received: true }); + + } catch (err) { + console.error('Error processing webhook:', err); + res.status(500).json({ error: 'Error processing webhook' }); + } + } +); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +// Start server if not in test mode +if (process.env.NODE_ENV !== 'test') { + app.listen(port, () => { + console.log(`Vercel webhook server running on port ${port}`); + console.log(`Webhook endpoint: http://localhost:${port}/webhooks/vercel`); + + if (!process.env.VERCEL_WEBHOOK_SECRET) { + console.warn('WARNING: VERCEL_WEBHOOK_SECRET not set in environment'); + } + }); +} + +module.exports = app; \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/express/test/webhook.test.js b/skills/vercel-webhooks/examples/express/test/webhook.test.js new file mode 100644 index 0000000..02ab01f --- /dev/null +++ b/skills/vercel-webhooks/examples/express/test/webhook.test.js @@ -0,0 +1,240 @@ +const request = require('supertest'); +const crypto = require('crypto'); +const app = require('../src/index'); + +// Test webhook secret +const TEST_SECRET = 'test_webhook_secret_12345'; + +// Helper to generate valid Vercel signature +function generateVercelSignature(body, secret) { + return crypto + .createHmac('sha1', secret) + .update(body) + .digest('hex'); +} + +// Helper to create test event payload +function createTestEvent(type, payload = {}) { + return { + id: 'event_test123', + type: type, + createdAt: Date.now(), + payload: payload, + region: 'sfo1' + }; +} + +describe('Vercel Webhook Handler', () => { + // Store original env + const originalEnv = process.env.VERCEL_WEBHOOK_SECRET; + + beforeAll(() => { + process.env.VERCEL_WEBHOOK_SECRET = TEST_SECRET; + }); + + afterAll(() => { + process.env.VERCEL_WEBHOOK_SECRET = originalEnv; + }); + + describe('POST /webhooks/vercel', () => { + it('should accept valid webhook with correct signature', async () => { + const event = createTestEvent('deployment.created', { + deployment: { + id: 'dpl_test123', + name: 'test-app', + url: 'https://test-app.vercel.app' + }, + project: { + id: 'prj_test123', + name: 'test-app' + }, + team: { + id: 'team_test123', + name: 'test-team' + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(Buffer.from(body), TEST_SECRET); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', signature) + .set('Content-Type', 'application/json') + .send(body); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: true }); + }); + + it('should reject webhook with missing signature', async () => { + const event = createTestEvent('deployment.created'); + + const response = await request(app) + .post('/webhooks/vercel') + .set('Content-Type', 'application/json') + .send(event); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Missing x-vercel-signature header'); + }); + + it('should reject webhook with invalid signature', async () => { + const event = createTestEvent('deployment.created'); + const body = JSON.stringify(event); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', 'invalid_signature_12345') + .set('Content-Type', 'application/json') + .send(body); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid signature'); + }); + + it('should reject webhook with wrong secret', async () => { + const event = createTestEvent('deployment.created'); + const body = JSON.stringify(event); + // Generate signature with wrong secret + const signature = generateVercelSignature(Buffer.from(body), 'wrong_secret'); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', signature) + .set('Content-Type', 'application/json') + .send(body); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid signature'); + }); + + it('should handle deployment.succeeded event', async () => { + const event = createTestEvent('deployment.succeeded', { + deployment: { + id: 'dpl_success123', + name: 'test-app', + url: 'https://test-app.vercel.app', + duration: 45000 + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(Buffer.from(body), TEST_SECRET); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', signature) + .set('Content-Type', 'application/json') + .send(body); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: true }); + }); + + it('should handle deployment.error event', async () => { + const event = createTestEvent('deployment.error', { + deployment: { + id: 'dpl_error123', + name: 'test-app', + error: 'Build failed' + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(Buffer.from(body), TEST_SECRET); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', signature) + .set('Content-Type', 'application/json') + .send(body); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: true }); + }); + + it('should handle project.created event', async () => { + const event = createTestEvent('project.created', { + project: { + id: 'prj_new123', + name: 'new-project' + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(Buffer.from(body), TEST_SECRET); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', signature) + .set('Content-Type', 'application/json') + .send(body); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: true }); + }); + + it('should handle unknown event types gracefully', async () => { + const event = createTestEvent('unknown.event.type', { + custom: 'data' + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(Buffer.from(body), TEST_SECRET); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', signature) + .set('Content-Type', 'application/json') + .send(body); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: true }); + }); + + it('should reject malformed JSON', async () => { + const body = 'invalid json{'; + const signature = generateVercelSignature(Buffer.from(body), TEST_SECRET); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', signature) + .set('Content-Type', 'application/json') + .send(body); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid JSON payload'); + }); + + it('should handle missing webhook secret config', async () => { + // Temporarily remove the secret + const tempSecret = process.env.VERCEL_WEBHOOK_SECRET; + delete process.env.VERCEL_WEBHOOK_SECRET; + + const event = createTestEvent('deployment.created'); + + const response = await request(app) + .post('/webhooks/vercel') + .set('x-vercel-signature', 'any_signature') + .set('Content-Type', 'application/json') + .send(event); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Webhook secret not configured'); + + // Restore secret + process.env.VERCEL_WEBHOOK_SECRET = tempSecret; + }); + }); + + describe('GET /health', () => { + it('should return health status', async () => { + const response = await request(app) + .get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/fastapi/.env.example b/skills/vercel-webhooks/examples/fastapi/.env.example new file mode 100644 index 0000000..3577603 --- /dev/null +++ b/skills/vercel-webhooks/examples/fastapi/.env.example @@ -0,0 +1,6 @@ +# Copy this file to .env and add your Vercel webhook secret +VERCEL_WEBHOOK_SECRET=your_webhook_secret_from_vercel_dashboard + +# Server settings +HOST=0.0.0.0 +PORT=8000 \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/fastapi/README.md b/skills/vercel-webhooks/examples/fastapi/README.md new file mode 100644 index 0000000..fa19951 --- /dev/null +++ b/skills/vercel-webhooks/examples/fastapi/README.md @@ -0,0 +1,134 @@ +# Vercel Webhooks - FastAPI Example + +Minimal example of receiving Vercel webhooks with signature verification using FastAPI. + +## Prerequisites + +- Python 3.9+ +- Vercel account with Pro or Enterprise plan +- Webhook secret from Vercel dashboard + +## Setup + +1. Create a virtual environment: + ```bash + python3 -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 Vercel webhook secret to `.env`: + ``` + VERCEL_WEBHOOK_SECRET=your_secret_from_vercel_dashboard + ``` + +## Run + +Start the server: +```bash +python main.py +``` + +Or with auto-reload for development: +```bash +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +Server runs on http://localhost:8000 + +The webhook endpoint is available at: +``` +POST http://localhost:8000/webhooks/vercel +``` + +API documentation is available at: +``` +http://localhost:8000/docs +``` + +## Test + +Run the test suite: +```bash +pytest test_webhook.py -v +``` + +## Production Deployment + +For production, use a production ASGI server: + +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +Or with Gunicorn: +```bash +gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker +``` + +## Local Testing with Hookdeck + +For local webhook development, use the Hookdeck CLI: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Start local tunnel +hookdeck listen 8000 --path /webhooks/vercel + +# Copy the webhook URL and add it to your Vercel dashboard +``` + +## Project Structure + +``` +. +├── main.py # FastAPI application with webhook handler +├── test_webhook.py # Tests with signature verification +├── requirements.txt +├── .env.example +└── README.md +``` + +## API Endpoints + +- `GET /health` - Health check endpoint +- `POST /webhooks/vercel` - Vercel webhook handler +- `GET /docs` - Swagger UI documentation +- `GET /redoc` - ReDoc documentation + +## Triggering Test Events + +To trigger a test webhook from Vercel: + +1. Make a small change to your project +2. Deploy with: `vercel --force` +3. This will trigger a `deployment.created` event + +## Common Issues + +### Signature Verification Failing + +- Ensure you're using the raw request body (bytes) +- Check that the secret in `.env` matches exactly +- Verify the header name: `x-vercel-signature` + +### Module Import Errors + +- Make sure you've activated the virtual environment +- Install all dependencies: `pip install -r requirements.txt` + +### Port Already in Use + +- Check if another process is using port 8000 +- Use a different port: `uvicorn main:app --port 8001` \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/fastapi/main.py b/skills/vercel-webhooks/examples/fastapi/main.py new file mode 100644 index 0000000..70e768f --- /dev/null +++ b/skills/vercel-webhooks/examples/fastapi/main.py @@ -0,0 +1,240 @@ +import os +import hmac +import hashlib +import logging +from typing import Dict, Any, Optional +from datetime import datetime + +from fastapi import FastAPI, Request, HTTPException, Header +from fastapi.responses import JSONResponse +from dotenv import load_dotenv +import uvicorn + +# Load environment variables +load_dotenv() + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Create FastAPI app +app = FastAPI( + title="Vercel Webhook Handler", + description="FastAPI application for handling Vercel webhooks", + version="1.0.0" +) + +# Get webhook secret from environment +WEBHOOK_SECRET = os.environ.get("VERCEL_WEBHOOK_SECRET", "") + + +def verify_signature(body: bytes, signature: str, secret: str) -> bool: + """Verify Vercel webhook signature using HMAC-SHA1.""" + if not secret: + logger.error("No webhook secret configured") + return False + + # Compute expected signature + expected_signature = hmac.new( + secret.encode(), + body, + hashlib.sha1 + ).hexdigest() + + # Timing-safe comparison + return hmac.compare_digest(signature, expected_signature) + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok", "service": "vercel-webhook-handler"} + + +@app.post("/webhooks/vercel") +async def handle_vercel_webhook( + request: Request, + x_vercel_signature: Optional[str] = Header(None) +): + """Handle Vercel webhook events.""" + + # Check signature header + if not x_vercel_signature: + logger.error("Missing x-vercel-signature header") + raise HTTPException(status_code=400, detail="Missing x-vercel-signature header") + + # Get raw body + body = await request.body() + + # Verify webhook secret is configured + if not WEBHOOK_SECRET: + logger.error("VERCEL_WEBHOOK_SECRET not configured") + raise HTTPException(status_code=500, detail="Webhook secret not configured") + + # Verify signature + if not verify_signature(body, x_vercel_signature, WEBHOOK_SECRET): + logger.error("Invalid webhook signature") + raise HTTPException(status_code=400, detail="Invalid signature") + + # Parse JSON payload + try: + event = await request.json() + except Exception as e: + logger.error(f"Invalid JSON payload: {e}") + raise HTTPException(status_code=400, detail="Invalid JSON payload") + + # Log event details + event_type = event.get("type", "unknown") + event_id = event.get("id", "unknown") + created_at = event.get("createdAt", 0) + + logger.info(f"Received Vercel webhook: {event_type}") + logger.info(f"Event ID: {event_id}") + logger.info(f"Created at: {datetime.fromtimestamp(created_at/1000).isoformat()}") + + # Extract payload + payload = event.get("payload", {}) + + # Handle different event types + try: + if event_type == "deployment.created": + deployment = payload.get("deployment", {}) + project = payload.get("project", {}) + team = payload.get("team", {}) + + logger.info(f"Deployment created: {deployment.get('id')}") + logger.info(f"Project: {project.get('name')}") + logger.info(f"URL: {deployment.get('url')}") + logger.info(f"Team: {team.get('name')}") + + # Extract git metadata if available + meta = deployment.get("meta", {}) + if meta: + logger.info(f"Git ref: {meta.get('githubCommitRef')}") + logger.info(f"Commit: {meta.get('githubCommitSha')}") + logger.info(f"Message: {meta.get('githubCommitMessage')}") + + elif event_type == "deployment.succeeded": + deployment = payload.get("deployment", {}) + logger.info(f"Deployment succeeded: {deployment.get('id')}") + logger.info(f"URL: {deployment.get('url')}") + logger.info(f"Duration: {deployment.get('duration')}ms") + + # Here you could trigger post-deployment tasks + # like smoke tests, cache warming, notifications, etc. + + elif event_type == "deployment.ready": + deployment = payload.get("deployment", {}) + logger.info(f"Deployment ready: {deployment.get('id')}") + logger.info(f"URL: {deployment.get('url')}") + # Deployment is now receiving traffic + + elif event_type == "deployment.error": + deployment = payload.get("deployment", {}) + logger.error(f"Deployment failed: {deployment.get('id')}") + logger.error(f"Error: {deployment.get('error')}") + + # Here you could send alerts to your team + # or create an incident ticket + + elif event_type == "deployment.canceled": + deployment = payload.get("deployment", {}) + logger.info(f"Deployment canceled: {deployment.get('id')}") + + elif event_type == "deployment.promoted": + deployment = payload.get("deployment", {}) + logger.info(f"Deployment promoted: {deployment.get('id')}") + logger.info(f"URL: {deployment.get('url')}") + logger.info(f"Target: {deployment.get('target')}") + # Could trigger cache clearing or feature flag updates + + elif event_type == "project.created": + project = payload.get("project", {}) + logger.info(f"Project created: {project.get('name')}") + logger.info(f"ID: {project.get('id')}") + logger.info(f"Framework: {project.get('framework')}") + + elif event_type == "project.removed": + project = payload.get("project", {}) + logger.info(f"Project removed: {project.get('name')}") + logger.info(f"ID: {project.get('id')}") + + # Clean up any external resources associated with this project + + elif event_type == "project.renamed": + project = payload.get("project", {}) + logger.info(f"Project renamed: {project.get('id')}") + logger.info(f"Old name: {project.get('oldName')}") + logger.info(f"New name: {project.get('name')}") + # Update external references + + elif event_type == "domain.created": + domain = payload.get("domain", {}) + project = payload.get("project", {}) + logger.info(f"Domain created: {domain.get('name')}") + logger.info(f"Project: {project.get('name')}") + + elif event_type == "integration-configuration.removed": + configuration = payload.get("configuration", {}) + integration = payload.get("integration", {}) + logger.info(f"Integration removed: {integration.get('name')}") + logger.info(f"Configuration ID: {configuration.get('id')}") + + elif event_type == "attack.detected": + attack = payload.get("attack", {}) + logger.warning(f"Attack detected: {attack.get('type')}") + logger.warning(f"Action taken: {attack.get('action')}") + logger.warning(f"Source IP: {attack.get('ip')}") + + # Here you could trigger security alerts + # or update firewall rules + + else: + logger.info(f"Unhandled event type: {event_type}") + logger.debug(f"Payload: {payload}") + + # Return success response + return JSONResponse( + content={"received": True}, + status_code=200 + ) + + except Exception as e: + logger.error(f"Error processing webhook: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Error processing webhook") + + +@app.get("/") +async def root(): + """Root endpoint with API information.""" + return { + "service": "Vercel Webhook Handler", + "endpoints": { + "health": "/health", + "webhook": "/webhooks/vercel", + "docs": "/docs", + "redoc": "/redoc" + } + } + + +# Run the application +if __name__ == "__main__": + host = os.environ.get("HOST", "0.0.0.0") + port = int(os.environ.get("PORT", 8000)) + + logger.info(f"Starting server on {host}:{port}") + + if not WEBHOOK_SECRET: + logger.warning("WARNING: VERCEL_WEBHOOK_SECRET not set in environment") + + uvicorn.run( + "main:app", + host=host, + port=port, + reload=False, + log_level="info" + ) \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/fastapi/requirements.txt b/skills/vercel-webhooks/examples/fastapi/requirements.txt new file mode 100644 index 0000000..a3116be --- /dev/null +++ b/skills/vercel-webhooks/examples/fastapi/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.128.0 +uvicorn[standard]>=0.24.0 +python-dotenv>=1.0.0 +httpx>=0.28.1 +pytest>=9.0.2 +pytest-asyncio>=0.21.0 \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/fastapi/test_webhook.py b/skills/vercel-webhooks/examples/fastapi/test_webhook.py new file mode 100644 index 0000000..7d4fe93 --- /dev/null +++ b/skills/vercel-webhooks/examples/fastapi/test_webhook.py @@ -0,0 +1,342 @@ +import os +import json +import hmac +import hashlib +from datetime import datetime + +import pytest +from fastapi.testclient import TestClient + +# Test webhook secret +TEST_SECRET = "test_webhook_secret_12345" + +# Set up test environment before importing app +os.environ["VERCEL_WEBHOOK_SECRET"] = TEST_SECRET + +from main import app + +# Create test client +client = TestClient(app) + + +def generate_vercel_signature(body: bytes, secret: str) -> str: + """Generate a valid Vercel webhook signature.""" + return hmac.new( + secret.encode(), + body, + hashlib.sha1 + ).hexdigest() + + +def create_test_event(event_type: str, payload: dict = None) -> dict: + """Create a test webhook event.""" + return { + "id": "event_test123", + "type": event_type, + "createdAt": int(datetime.now().timestamp() * 1000), + "payload": payload or {}, + "region": "sfo1" + } + + +class TestVercelWebhook: + """Test suite for Vercel webhook handler.""" + + @pytest.fixture(autouse=True) + def setup(self, monkeypatch): + """Set up test environment.""" + # Set test webhook secret + monkeypatch.setenv("VERCEL_WEBHOOK_SECRET", TEST_SECRET) + + def test_health_check(self): + """Test health check endpoint.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "service": "vercel-webhook-handler"} + + def test_root_endpoint(self): + """Test root endpoint.""" + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert data["service"] == "Vercel Webhook Handler" + assert "endpoints" in data + + def test_valid_webhook_signature(self): + """Test webhook with valid signature.""" + event = create_test_event("deployment.created", { + "deployment": { + "id": "dpl_test123", + "name": "test-app", + "url": "https://test-app.vercel.app" + }, + "project": { + "id": "prj_test123", + "name": "test-app" + }, + "team": { + "id": "team_test123", + "name": "test-team" + } + }) + + body = json.dumps(event).encode() + signature = generate_vercel_signature(body, TEST_SECRET) + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_missing_signature(self): + """Test webhook with missing signature.""" + event = create_test_event("deployment.created") + + response = client.post( + "/webhooks/vercel", + json=event, + headers={"content-type": "application/json"} + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Missing x-vercel-signature header" + + def test_invalid_signature(self): + """Test webhook with invalid signature.""" + event = create_test_event("deployment.created") + body = json.dumps(event).encode() + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": "invalid_signature_12345", + "content-type": "application/json" + } + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid signature" + + def test_wrong_secret(self): + """Test webhook with signature from wrong secret.""" + event = create_test_event("deployment.created") + body = json.dumps(event).encode() + wrong_signature = generate_vercel_signature(body, "wrong_secret") + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": wrong_signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid signature" + + def test_deployment_succeeded_event(self): + """Test deployment.succeeded event handling.""" + event = create_test_event("deployment.succeeded", { + "deployment": { + "id": "dpl_success123", + "name": "test-app", + "url": "https://test-app.vercel.app", + "duration": 45000 + } + }) + + body = json.dumps(event).encode() + signature = generate_vercel_signature(body, TEST_SECRET) + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_deployment_error_event(self): + """Test deployment.error event handling.""" + event = create_test_event("deployment.error", { + "deployment": { + "id": "dpl_error123", + "error": "Build failed" + } + }) + + body = json.dumps(event).encode() + signature = generate_vercel_signature(body, TEST_SECRET) + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_project_created_event(self): + """Test project.created event handling.""" + event = create_test_event("project.created", { + "project": { + "id": "prj_new123", + "name": "new-project", + "framework": "nextjs" + } + }) + + body = json.dumps(event).encode() + signature = generate_vercel_signature(body, TEST_SECRET) + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_attack_detected_event(self): + """Test attack.detected event handling.""" + event = create_test_event("attack.detected", { + "attack": { + "type": "ddos", + "action": "blocked", + "ip": "192.0.2.1" + } + }) + + body = json.dumps(event).encode() + signature = generate_vercel_signature(body, TEST_SECRET) + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_unknown_event_type(self): + """Test handling of unknown event types.""" + event = create_test_event("unknown.event.type", { + "custom": "data" + }) + + body = json.dumps(event).encode() + signature = generate_vercel_signature(body, TEST_SECRET) + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_malformed_json(self): + """Test webhook with malformed JSON.""" + body = b"invalid json{" + signature = generate_vercel_signature(body, TEST_SECRET) + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid JSON payload" + + def test_missing_webhook_secret(self, monkeypatch): + """Test behavior when webhook secret is not configured.""" + # Remove the secret + monkeypatch.delenv("VERCEL_WEBHOOK_SECRET", raising=False) + # Also need to update the app's cached value + import main + main.WEBHOOK_SECRET = "" + + event = create_test_event("deployment.created") + body = json.dumps(event).encode() + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": "any_signature", + "content-type": "application/json" + } + ) + + assert response.status_code == 500 + assert response.json()["detail"] == "Webhook secret not configured" + + # Restore the secret for other tests + main.WEBHOOK_SECRET = TEST_SECRET + + def test_deployment_with_git_metadata(self): + """Test deployment event with git metadata.""" + event = create_test_event("deployment.created", { + "deployment": { + "id": "dpl_git123", + "name": "test-app", + "url": "https://test-app.vercel.app", + "meta": { + "githubCommitRef": "main", + "githubCommitSha": "abc123def456", + "githubCommitMessage": "feat: Add new feature" + } + }, + "project": { + "id": "prj_test123", + "name": "test-app" + } + }) + + body = json.dumps(event).encode() + signature = generate_vercel_signature(body, TEST_SECRET) + + response = client.post( + "/webhooks/vercel", + content=body, + headers={ + "x-vercel-signature": signature, + "content-type": "application/json" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/nextjs/.env.example b/skills/vercel-webhooks/examples/nextjs/.env.example new file mode 100644 index 0000000..ceb864a --- /dev/null +++ b/skills/vercel-webhooks/examples/nextjs/.env.example @@ -0,0 +1,2 @@ +# Copy this file to .env.local and add your Vercel webhook secret +VERCEL_WEBHOOK_SECRET=your_webhook_secret_from_vercel_dashboard \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/nextjs/README.md b/skills/vercel-webhooks/examples/nextjs/README.md new file mode 100644 index 0000000..108001f --- /dev/null +++ b/skills/vercel-webhooks/examples/nextjs/README.md @@ -0,0 +1,123 @@ +# Vercel Webhooks - Next.js Example + +Minimal example of receiving Vercel webhooks with signature verification using Next.js App Router. + +## Prerequisites + +- Node.js 18+ +- Vercel account with Pro or Enterprise plan +- Webhook secret from Vercel dashboard + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env.local + ``` + +3. Add your Vercel webhook secret to `.env.local`: + ``` + VERCEL_WEBHOOK_SECRET=your_secret_from_vercel_dashboard + ``` + +## Run + +Start the development server: +```bash +npm run dev +``` + +Server runs on http://localhost:3000 + +The webhook endpoint is available at: +``` +POST http://localhost:3000/webhooks/vercel +``` + +## Test + +Run the test suite: +```bash +npm test +``` + +## Production Build + +Build for production: +```bash +npm run build +npm start +``` + +## Local Testing with Hookdeck + +For local webhook development, use the Hookdeck CLI: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Start local tunnel +hookdeck listen 3000 --path /webhooks/vercel + +# Copy the webhook URL and add it to your Vercel dashboard +``` + +## Project Structure + +``` +. +├── app/ +│ └── webhooks/ +│ └── vercel/ +│ └── route.ts # Webhook handler +├── test/ +│ └── webhook.test.ts # Tests with signature verification +├── vitest.config.ts +├── .env.example +├── package.json +├── tsconfig.json +└── README.md +``` + +## API Route Details + +The webhook handler is implemented as a Next.js App Router API route: + +- Location: `app/webhooks/vercel/route.ts` +- Method: POST only +- Body parsing disabled (uses raw body for signature verification) +- TypeScript for type safety + +## Triggering Test Events + +To trigger a test webhook from Vercel: + +1. Make a small change to your project +2. Deploy with: `vercel --force` +3. This will trigger a `deployment.created` event + +## Common Issues + +### Signature Verification Failing + +- Ensure you're using the raw request body (not parsed JSON) +- Check that the secret in `.env.local` matches exactly +- Verify the header name is lowercase: `x-vercel-signature` + +### Environment Variables Not Loading + +- Use `.env.local` for local development (not `.env`) +- Restart the dev server after changing env variables +- Access via `process.env.VERCEL_WEBHOOK_SECRET` + +### Type Errors + +- This example uses TypeScript +- Run `npm run build` to check for type errors +- VS Code should show inline type errors \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/nextjs/app/webhooks/vercel/route.ts b/skills/vercel-webhooks/examples/nextjs/app/webhooks/vercel/route.ts new file mode 100644 index 0000000..23c3d0b --- /dev/null +++ b/skills/vercel-webhooks/examples/nextjs/app/webhooks/vercel/route.ts @@ -0,0 +1,204 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; + +// Vercel webhook event types +interface VercelWebhookEvent { + id: string; + type: string; + createdAt: number; + payload: Record; + region?: string; +} + +// Verify Vercel webhook signature +function verifySignature(rawBody: string | Buffer, signature: string, secret: string): boolean { + const expectedSignature = crypto + .createHmac('sha1', secret) + .update(rawBody) + .digest('hex'); + + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch { + // Buffer length mismatch = invalid signature + return false; + } +} + +export async function POST(request: NextRequest) { + const signature = request.headers.get('x-vercel-signature'); + + if (!signature) { + console.error('Missing x-vercel-signature header'); + return NextResponse.json( + { error: 'Missing x-vercel-signature header' }, + { status: 400 } + ); + } + + const secret = process.env.VERCEL_WEBHOOK_SECRET; + if (!secret) { + console.error('VERCEL_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 = verifySignature(rawBody, signature, secret); + + if (!isValid) { + console.error('Invalid webhook signature'); + return NextResponse.json( + { error: 'Invalid signature' }, + { status: 400 } + ); + } + + // Parse verified payload + let event: VercelWebhookEvent; + try { + event = JSON.parse(rawBody); + } catch (err) { + console.error('Invalid JSON payload:', err); + return NextResponse.json( + { error: 'Invalid JSON payload' }, + { status: 400 } + ); + } + + // Log the event + console.log(`Received Vercel webhook: ${event.type}`); + console.log('Event ID:', event.id); + console.log('Created at:', new Date(event.createdAt).toISOString()); + + // Handle different event types + try { + switch (event.type) { + case 'deployment.created': + console.log('Deployment created:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + url: event.payload.deployment?.url, + project: event.payload.project?.name, + team: event.payload.team?.name, + commit: event.payload.deployment?.meta?.githubCommitRef, + message: event.payload.deployment?.meta?.githubCommitMessage, + }); + break; + + case 'deployment.succeeded': + console.log('Deployment succeeded:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + url: event.payload.deployment?.url, + duration: event.payload.deployment?.duration, + }); + // You could trigger post-deployment tasks here + break; + + case 'deployment.ready': + console.log('Deployment ready:', { + id: event.payload.deployment?.id, + url: event.payload.deployment?.url, + }); + // Deployment is now receiving traffic + break; + + case 'deployment.error': + console.error('Deployment failed:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + error: event.payload.deployment?.error, + }); + // You could send alerts here + break; + + case 'deployment.canceled': + console.log('Deployment canceled:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + }); + break; + + case 'deployment.promoted': + console.log('Deployment promoted:', { + id: event.payload.deployment?.id, + name: event.payload.deployment?.name, + url: event.payload.deployment?.url, + target: event.payload.deployment?.target, + }); + // Could trigger cache clearing or feature flag updates + break; + + case 'project.created': + console.log('Project created:', { + id: event.payload.project?.id, + name: event.payload.project?.name, + framework: event.payload.project?.framework, + }); + break; + + case 'project.removed': + console.log('Project removed:', { + id: event.payload.project?.id, + name: event.payload.project?.name, + }); + // Clean up any external resources + break; + + case 'project.renamed': + console.log('Project renamed:', { + id: event.payload.project?.id, + oldName: event.payload.project?.oldName, + newName: event.payload.project?.name, + }); + // Update external references + break; + + case 'domain.created': + console.log('Domain created:', { + domain: event.payload.domain?.name, + project: event.payload.project?.name, + }); + break; + + case 'integration-configuration.removed': + console.log('Integration removed:', { + id: event.payload.configuration?.id, + integration: event.payload.integration?.name, + }); + break; + + case 'attack.detected': + console.warn('Attack detected:', { + type: event.payload.attack?.type, + action: event.payload.attack?.action, + ip: event.payload.attack?.ip, + }); + // Security alerting + break; + + default: + console.log('Unhandled event type:', event.type); + console.log('Payload:', JSON.stringify(event.payload, null, 2)); + } + + // Return success response + return NextResponse.json({ received: true }); + + } catch (err) { + console.error('Error processing webhook:', err); + return NextResponse.json( + { error: 'Error processing webhook' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/nextjs/package.json b/skills/vercel-webhooks/examples/nextjs/package.json new file mode 100644 index 0000000..156ea05 --- /dev/null +++ b/skills/vercel-webhooks/examples/nextjs/package.json @@ -0,0 +1,27 @@ +{ + "name": "vercel-webhooks-nextjs", + "version": "1.0.0", + "description": "Next.js example for handling Vercel webhooks", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run" + }, + "dependencies": { + "next": "^16.1.6", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18", + "@vitejs/plugin-react": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/nextjs/test/webhook.test.ts b/skills/vercel-webhooks/examples/nextjs/test/webhook.test.ts new file mode 100644 index 0000000..7a4884b --- /dev/null +++ b/skills/vercel-webhooks/examples/nextjs/test/webhook.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crypto from 'crypto'; + +// Test webhook secret +const TEST_SECRET = 'test_webhook_secret_12345'; + +// Helper to generate valid Vercel signature +function generateVercelSignature(body: string | Buffer, secret: string): string { + return crypto + .createHmac('sha1', secret) + .update(body) + .digest('hex'); +} + +// Helper to create test event payload +function createTestEvent(type: string, payload: any = {}): any { + return { + id: 'event_test123', + type: type, + createdAt: Date.now(), + payload: payload, + region: 'sfo1' + }; +} + +// Mock Next.js request +class MockNextRequest { + private body: string; + headers: Map; + + constructor(body: any, headers: Record = {}) { + this.body = typeof body === 'string' ? body : JSON.stringify(body); + this.headers = new Map(Object.entries(headers)); + } + + async text(): Promise { + return this.body; + } +} + +// Import the route handler +let POST: any; + +describe('Vercel Webhook Handler', () => { + // Store original env + const originalEnv = process.env.VERCEL_WEBHOOK_SECRET; + + beforeAll(async () => { + process.env.VERCEL_WEBHOOK_SECRET = TEST_SECRET; + // Dynamically import to get fresh env values + const route = await import('../app/webhooks/vercel/route'); + POST = route.POST; + }); + + afterAll(() => { + process.env.VERCEL_WEBHOOK_SECRET = originalEnv; + }); + + describe('POST /webhooks/vercel', () => { + it('should accept valid webhook with correct signature', async () => { + const event = createTestEvent('deployment.created', { + deployment: { + id: 'dpl_test123', + name: 'test-app', + url: 'https://test-app.vercel.app', + meta: { + githubCommitRef: 'main', + githubCommitMessage: 'Test commit' + } + }, + project: { + id: 'prj_test123', + name: 'test-app' + }, + team: { + id: 'team_test123', + name: 'test-team' + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(body, TEST_SECRET); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': signature, + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + it('should reject webhook with missing signature', async () => { + const event = createTestEvent('deployment.created'); + + const request = new MockNextRequest(event, { + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Missing x-vercel-signature header'); + }); + + it('should reject webhook with invalid signature', async () => { + const event = createTestEvent('deployment.created'); + const body = JSON.stringify(event); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': 'invalid_signature_12345', + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid signature'); + }); + + it('should reject webhook with wrong secret', async () => { + const event = createTestEvent('deployment.created'); + const body = JSON.stringify(event); + const signature = generateVercelSignature(body, 'wrong_secret'); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': signature, + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid signature'); + }); + + it('should handle deployment.succeeded event', async () => { + const event = createTestEvent('deployment.succeeded', { + deployment: { + id: 'dpl_success123', + name: 'test-app', + url: 'https://test-app.vercel.app', + duration: 45000 + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(body, TEST_SECRET); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': signature, + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + it('should handle deployment.error event', async () => { + const event = createTestEvent('deployment.error', { + deployment: { + id: 'dpl_error123', + name: 'test-app', + error: 'Build failed' + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(body, TEST_SECRET); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': signature, + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + it('should handle project.created event', async () => { + const event = createTestEvent('project.created', { + project: { + id: 'prj_new123', + name: 'new-project', + framework: 'nextjs' + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(body, TEST_SECRET); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': signature, + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + it('should handle attack.detected event', async () => { + const event = createTestEvent('attack.detected', { + attack: { + type: 'ddos', + action: 'blocked', + ip: '192.0.2.1' + } + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(body, TEST_SECRET); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': signature, + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + it('should handle unknown event types gracefully', async () => { + const event = createTestEvent('unknown.event.type', { + custom: 'data' + }); + + const body = JSON.stringify(event); + const signature = generateVercelSignature(body, TEST_SECRET); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': signature, + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + it('should reject malformed JSON', async () => { + const body = 'invalid json{'; + const signature = generateVercelSignature(body, TEST_SECRET); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': signature, + 'content-type': 'application/json' + }); + + const response = await POST(request as any); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid JSON payload'); + }); + + it('should handle missing webhook secret config', async () => { + // Temporarily remove the secret + const tempSecret = process.env.VERCEL_WEBHOOK_SECRET; + delete process.env.VERCEL_WEBHOOK_SECRET; + + // Re-import to get fresh env + const { POST: FreshPOST } = await import('../app/webhooks/vercel/route'); + + const event = createTestEvent('deployment.created'); + const body = JSON.stringify(event); + + const request = new MockNextRequest(body, { + 'x-vercel-signature': 'any_signature', + 'content-type': 'application/json' + }); + + const response = await FreshPOST(request as any); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('Webhook secret not configured'); + + // Restore secret + process.env.VERCEL_WEBHOOK_SECRET = tempSecret; + }); + }); +}); \ No newline at end of file diff --git a/skills/vercel-webhooks/examples/nextjs/vitest.config.ts b/skills/vercel-webhooks/examples/nextjs/vitest.config.ts new file mode 100644 index 0000000..c5142ac --- /dev/null +++ b/skills/vercel-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: { + globals: true, + environment: 'node', + }, +}); \ No newline at end of file diff --git a/skills/vercel-webhooks/references/overview.md b/skills/vercel-webhooks/references/overview.md new file mode 100644 index 0000000..7634d6e --- /dev/null +++ b/skills/vercel-webhooks/references/overview.md @@ -0,0 +1,124 @@ +# Vercel Webhooks Overview + +## What Are Vercel Webhooks? + +Vercel webhooks are HTTP POST requests that Vercel sends to your application when specific events occur in your Vercel projects, deployments, or team. They enable real-time notifications and automated workflows based on deployment status, project changes, and other platform events. + +## Common Event Types + +### Deployment Events + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `deployment.created` | A new deployment is initiated | Start deployment monitoring, notify team chat | +| `deployment.succeeded` | Deployment completes successfully | Trigger smoke tests, update external status | +| `deployment.ready` | Deployment is ready to receive traffic | Run end-to-end tests, warm up caches | +| `deployment.error` | Deployment fails to complete | Alert on-call team, create incident ticket | +| `deployment.canceled` | User cancels a deployment | Clean up temporary resources | +| `deployment.promoted` | Production deployment is promoted | Update feature flags, clear CDN cache | + +### Project Events + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `project.created` | New project is created | Set up monitoring, create dashboards | +| `project.removed` | Project is deleted | Archive data, clean up resources | +| `project.renamed` | Project name changes | Update external references | +| `domain.created` | Domain is added to project | Configure DNS, update SSL certificates | + +### Integration Events + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `integration-configuration.removed` | Integration is uninstalled | Clean up integration data | + +### Security Events + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `attack.detected`* | Firewall detects attack (may require certain plans) | Security alerting, block IPs | + +*Note: The `attack.detected` event may be limited to certain plan types or may require specific security features enabled. Check Vercel's current documentation for availability. + +## Event Payload Structure + +All Vercel webhook events follow a standard structure: + +```json +{ + "id": "event_2XqF5example", + "type": "deployment.created", + "createdAt": 1698345600000, + "payload": { + // Event-specific data + }, + "region": "sfo1" +} +``` + +### Common Payload Fields + +- `id`: Unique identifier for this webhook event +- `type`: The event type (e.g., `deployment.created`) +- `createdAt`: JavaScript timestamp (milliseconds since epoch) +- `payload`: Event-specific data that varies by event type +- `region`: Vercel region where the event originated + +### Example: deployment.created Payload + +```json +{ + "id": "event_2XqF5example", + "type": "deployment.created", + "createdAt": 1698345600000, + "payload": { + "deployment": { + "id": "dpl_FjHqKqFexample", + "name": "my-app", + "url": "https://my-app-git-main-team.vercel.app", + "meta": { + "githubCommitRef": "main", + "githubCommitSha": "abc123def456", + "githubCommitMessage": "Update homepage" + } + }, + "project": { + "id": "prj_Qmexample", + "name": "my-app" + }, + "team": { + "id": "team_Vexample", + "name": "my-team" + } + } +} +``` + +## Webhook Requirements + +### Plan Requirements +- Available on Pro and Enterprise plans only +- Free plans do not have access to webhooks + +### Limits +- Maximum of 20 custom webhooks per team +- Response timeout: 30 seconds +- Retry policy: Exponential backoff up to 24 hours + +### Endpoint Requirements +- Must accept POST requests +- Must be publicly accessible (not behind authentication) +- Should return 2xx status code to acknowledge receipt +- Should process webhooks asynchronously for long operations + +## Best Practices + +1. **Always verify signatures** - Ensure webhooks are from Vercel +2. **Handle events idempotently** - Same webhook might be delivered multiple times +3. **Return 200 quickly** - Process events asynchronously to avoid timeouts +4. **Log all events** - Helps with debugging and audit trails +5. **Handle unknown events** - New event types may be added + +## Full Event Reference + +For the complete and up-to-date list of webhook events, see [Vercel's webhook documentation](https://vercel.com/docs/webhooks). \ No newline at end of file diff --git a/skills/vercel-webhooks/references/setup.md b/skills/vercel-webhooks/references/setup.md new file mode 100644 index 0000000..10bd6a6 --- /dev/null +++ b/skills/vercel-webhooks/references/setup.md @@ -0,0 +1,164 @@ +# Setting Up Vercel Webhooks + +## Prerequisites + +- Vercel account with Pro or Enterprise plan +- Your application's webhook endpoint URL +- Access to team settings in Vercel dashboard + +## Get Your Signing Secret + +1. Go to [Vercel Dashboard](https://vercel.com/dashboard) +2. Navigate to your team settings: + - Click on your team name in the top navigation + - Select "Settings" from the dropdown +3. Click on "Webhooks" in the left sidebar +4. Click "Create Webhook" button + +## Register Your Endpoint + +1. **Enter Webhook URL** + - Provide your public endpoint URL (e.g., `https://api.example.com/webhooks/vercel`) + - Must be HTTPS for production + - Cannot require authentication headers + +2. **Select Events to Receive** + + Common selections for different use cases: + + **Deployment Monitoring:** + - `deployment.created` + - `deployment.succeeded` + - `deployment.error` + - `deployment.canceled` + + **Project Management:** + - `project.created` + - `project.removed` + - `project.renamed` + + **Security Monitoring:** + - `attack.detected` + +3. **Choose Project Scope** + - **All Projects**: Receive events for all projects in your team + - **Specific Projects**: Select individual projects to monitor + +4. **Save Your Webhook Secret** + - After creating the webhook, Vercel displays a secret + - **IMPORTANT**: This secret is shown only once! + - Copy it immediately and store securely + - Format: Random string (not prefixed like some providers) + +## Test Your Webhook + +Vercel doesn't provide a built-in test button, but you can: + +1. **Trigger a Real Event** + - Create a test deployment: `vercel --force` + - This sends a `deployment.created` event + +2. **Use Hookdeck CLI for Testing** + ```bash + # Install Hookdeck CLI + npm install -g hookdeck-cli + + # Create a local tunnel + hookdeck listen 3000 --path /webhooks/vercel + + # Update your Vercel webhook URL to the Hookdeck URL + # Now you can inspect all webhook deliveries locally + ``` + +## Managing Webhooks + +### View Webhook Details +1. Go to Settings → Webhooks +2. Click on a webhook to see: + - Endpoint URL + - Selected events + - Recent deliveries (last 24 hours) + - Delivery status and response codes + +### Update Webhook Configuration +1. Click on the webhook you want to modify +2. You can change: + - Events selection + - Project scope + - Endpoint URL +3. Note: You cannot view or regenerate the secret + +### Delete a Webhook +1. Click on the webhook +2. Click "Delete Webhook" +3. Confirm deletion + +## Multiple Environments + +For different environments, create separate webhooks: + +``` +Production: https://api.example.com/webhooks/vercel +Staging: https://staging-api.example.com/webhooks/vercel +Development: https://your-tunnel.hookdeck.com/webhooks/vercel +``` + +Each webhook has its own secret - store them separately: +```bash +# Production +VERCEL_WEBHOOK_SECRET_PROD=secret_prod_xxx + +# Staging +VERCEL_WEBHOOK_SECRET_STAGING=secret_staging_xxx + +# Development (local tunnel) +VERCEL_WEBHOOK_SECRET_DEV=secret_dev_xxx +``` + +## Troubleshooting + +### Webhook Not Firing +- Verify you're on Pro or Enterprise plan +- Check event selection matches what you expect +- Ensure project scope includes your project +- Confirm endpoint is publicly accessible + +### Signature Verification Failing +- Ensure you're using the raw request body +- Check you're using SHA1 (not SHA256) +- Verify the secret matches exactly (no extra spaces) +- Confirm header name is lowercase: `x-vercel-signature` + +### Timeouts +- Vercel waits 30 seconds for response +- Return 200 immediately, process async +- Check your server logs for slow operations + +### Missing Events +- Some events may be delayed during high load +- Check Vercel status page for issues +- Review webhook history in dashboard + +## Security Best Practices + +1. **Never expose your webhook secret** + - Don't commit to version control + - Use environment variables + - Rotate if compromised + +2. **Always verify signatures** + - Reject requests without valid signatures + - Use constant-time comparison + +3. **Validate event data** + - Check required fields exist + - Validate IDs match expected format + - Handle missing/null values gracefully + +4. **Use HTTPS endpoints only** + - HTTP endpoints are not allowed + - Ensure valid SSL certificate + +5. **Implement rate limiting** + - Protect against webhook floods + - Use per-IP or per-signature limiting \ No newline at end of file diff --git a/skills/vercel-webhooks/references/verification.md b/skills/vercel-webhooks/references/verification.md new file mode 100644 index 0000000..6528d7c --- /dev/null +++ b/skills/vercel-webhooks/references/verification.md @@ -0,0 +1,285 @@ +# Vercel Signature Verification + +## How It Works + +Vercel uses HMAC-SHA1 to sign webhook payloads. The signature is sent in the `x-vercel-signature` header and is computed by: + +1. Taking the raw request body (as bytes) +2. Creating an HMAC using SHA1 with your webhook secret +3. Encoding the result as a hexadecimal string + +## Implementation + +### Node.js/JavaScript + +```javascript +const crypto = require('crypto'); + +function verifyVercelSignature(rawBody, signature, secret) { + // Compute expected signature + const expectedSignature = crypto + .createHmac('sha1', secret) + .update(rawBody) + .digest('hex'); + + // Use timing-safe comparison to prevent timing attacks + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch (err) { + // Buffer length mismatch = invalid signature + return false; + } +} + +// Usage in Express +app.post('/webhooks/vercel', + express.raw({ type: 'application/json' }), + (req, res) => { + const signature = req.headers['x-vercel-signature']; + + if (!signature) { + return res.status(400).send('Missing signature'); + } + + const isValid = verifyVercelSignature( + req.body, + signature, + process.env.VERCEL_WEBHOOK_SECRET + ); + + if (!isValid) { + return res.status(400).send('Invalid signature'); + } + + // Process verified webhook... + } +); +``` + +### Python + +```python +import hmac +import hashlib + +def verify_vercel_signature(body: bytes, signature: str, secret: str) -> bool: + """Verify Vercel webhook signature using HMAC-SHA1.""" + expected_signature = hmac.new( + secret.encode(), + body, + hashlib.sha1 + ).hexdigest() + + # Constant-time comparison + return hmac.compare_digest(signature, expected_signature) + +# Usage in FastAPI +from fastapi import Request, HTTPException, Header + +@app.post("/webhooks/vercel") +async def handle_webhook( + request: Request, + x_vercel_signature: str = Header(None) +): + if not x_vercel_signature: + raise HTTPException(status_code=400, detail="Missing signature") + + body = await request.body() + + if not verify_vercel_signature( + body, + x_vercel_signature, + os.environ["VERCEL_WEBHOOK_SECRET"] + ): + raise HTTPException(status_code=400, detail="Invalid signature") + + # Process verified webhook... +``` + +## Common Gotchas + +### 1. Raw Body Parsing + +**Problem**: Most web frameworks parse JSON automatically, but signature verification requires the raw bytes. + +**Solution**: Configure your framework to provide raw body: + +```javascript +// Express - MUST use raw body parser +app.use('/webhooks/vercel', express.raw({ type: 'application/json' })); + +// Next.js - Disable body parsing +export const config = { + api: { + bodyParser: false + } +}; + +// FastAPI - Use request.body() +body = await request.body() // Don't use await request.json() +``` + +### 2. Algorithm Confusion + +**Problem**: Using SHA256 instead of SHA1 (common with other providers). + +**Solution**: Vercel specifically uses SHA1: + +```javascript +// CORRECT +crypto.createHmac('sha1', secret) + +// WRONG - This is for Stripe/GitHub +crypto.createHmac('sha256', secret) +``` + +### 3. Signature Format + +**Problem**: Expecting base64 or prefixed signatures. + +**Solution**: Vercel signatures are plain hex strings: + +```javascript +// Vercel signature format +'a1b2c3d4e5f6...' // Plain hex, no prefix + +// NOT like Stripe +'t=1234567,v1=abc123...' // Stripe uses timestamp prefix + +// NOT base64 +'YWJjMTIz...' // Some providers use base64 +``` + +### 4. Header Name Case Sensitivity + +**Problem**: Different frameworks handle header names differently. + +**Solution**: Headers are case-insensitive in HTTP, but check your framework: + +```javascript +// Express - converts to lowercase +req.headers['x-vercel-signature'] + +// Some frameworks might preserve case +req.headers['X-Vercel-Signature'] // May not work + +// Python/FastAPI - Use Header() for automatic handling +x_vercel_signature: str = Header(None) +``` + +### 5. Timing Attacks + +**Problem**: Using regular string comparison can leak information through timing. + +**Solution**: Always use constant-time comparison: + +```javascript +// Node.js +crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) + +// Python +hmac.compare_digest(a, b) + +// NEVER use +signature === expectedSignature // Vulnerable to timing attacks +``` + +## Debugging Verification Failures + +### Step 1: Check the Header + +```javascript +console.log('Signature header:', req.headers['x-vercel-signature']); +console.log('All headers:', req.headers); +``` + +Common issues: +- Missing header (not a Vercel webhook) +- Empty header (configuration issue) +- Header in different case + +### Step 2: Verify Raw Body + +```javascript +console.log('Body type:', typeof req.body); +console.log('Body length:', req.body.length); +console.log('First 100 chars:', req.body.toString().substring(0, 100)); +``` + +Should show: +- Type: `object` (Buffer) or `string` +- Non-zero length +- Raw JSON starting with `{` + +### Step 3: Check Secret + +```javascript +console.log('Secret exists:', !!process.env.VERCEL_WEBHOOK_SECRET); +console.log('Secret length:', process.env.VERCEL_WEBHOOK_SECRET?.length); +// Never log the actual secret! +``` + +Common issues: +- Missing environment variable +- Extra whitespace +- Wrong environment (dev/prod mismatch) + +### Step 4: Compare Signatures + +```javascript +const expected = crypto.createHmac('sha1', secret).update(body).digest('hex'); +console.log('Received:', signature); +console.log('Expected:', expected); +console.log('Match:', signature === expected); +``` + +If they don't match: +- Verify you're using SHA1 (not SHA256) +- Check body hasn't been modified +- Ensure secret is correct + +### Step 5: Test with Known Values + +Create a test to verify your implementation: + +```javascript +const testBody = '{"test":true}'; +const testSecret = 'test_secret'; +const expectedSig = crypto + .createHmac('sha1', testSecret) + .update(testBody) + .digest('hex'); + +console.log('Test signature:', expectedSig); +// Should output: '7fd2b9c8d2c5f85c7e8f3a1b4d6e9a0c3f8b2d1e' +``` + +## Security Best Practices + +1. **Always verify signatures** - Never trust webhooks without verification +2. **Fail fast** - Return 400 immediately for invalid signatures +3. **Log failures** - Track verification failures for security monitoring +4. **Use environment variables** - Never hardcode secrets +5. **Implement rate limiting** - Protect against brute force attempts +6. **Monitor for anomalies** - Unusual patterns may indicate attacks + +## Error Response Standards + +Return appropriate HTTP status codes: + +```javascript +// Missing signature header +res.status(400).json({ error: 'Missing x-vercel-signature header' }); + +// Invalid signature +res.status(400).json({ error: 'Invalid signature' }); + +// Valid signature but processing failed +res.status(500).json({ error: 'Internal server error' }); + +// Success +res.status(200).json({ received: true }); +``` \ No newline at end of file