A secure Netlify webhook handler for processing fund application form submissions. This webhook receives form submissions from Netlify Forms, verifies their authenticity, stores the data, and sends notifications via email and Slack.
- ✅ Secure JWT Signature Verification - Validates webhook authenticity using JWT (JWS) with HMAC-SHA256
- ✅ Data Storage - Stores submissions in Val Town blob storage with deduplication
- ✅ Email Notifications - Sends formatted emails to the fund team
- ✅ Slack Integration - Posts notifications to Slack channels
- ✅ Comprehensive Error Handling - Proper HTTP status codes and error responses
- ✅ Request Logging - Detailed logging for debugging and monitoring
- ✅ Test Script - Automated testing with signature generation
The webhook implements robust security by verifying Netlify's JWT (JSON Web Signature) for every incoming request. This ensures that only authentic requests from Netlify are processed.
-
Netlify Signs the Request
- When Netlify sends a webhook, it creates a JWT token
- The JWT payload includes a SHA-256 hash of the request body
- Netlify signs the JWT using your shared secret (
NETLIFY_FORM_JWS) - The signed JWT is sent in the
x-webhook-signatureheader
-
Webhook Verification Process
// 1. Extract JWT from header const jwtToken = req.headers.get("x-webhook-signature"); // 2. Calculate SHA-256 hash of request body const bodyHash = await crypto.subtle.digest("SHA-256", requestBody); // 3. Decode JWT header and payload (without verifying yet) const [headerB64, payloadB64, signatureB64] = jwtToken.split("."); const payload = JSON.parse(atob(payloadB64)); // 4. Compare body hash with hash in JWT payload if (payload.sha256 !== hex(bodyHash)) { return 401; // Body was tampered with } // 5. Verify JWT signature using shared secret const isValid = await crypto.subtle.verify( { name: "HMAC", hash: "SHA-256" }, signingKey, signature, dataToSign ); -
Security Guarantees
- Authenticity: Only requests signed with your secret are accepted
- Integrity: Any tampering with the request body is detected
- Non-repudiation: The signature proves the request came from Netlify
| Step | Action | Security Check |
|---|---|---|
| 1 | Extract JWT from x-webhook-signature header | Header presence |
| 2 | Calculate SHA-256 hash of request body | Body integrity |
| 3 | Decode JWT payload | JWT format validation |
| 4 | Compare body hash with JWT payload hash | Payload matching |
| 5 | Verify JWT signature with HMAC-SHA256 | Cryptographic verification |
| Status Code | Condition | Response |
|---|---|---|
401 Unauthorized | Invalid or missing signature | {"ok": false, "error": "Invalid signature"} |
400 Bad Request | Missing headers or unknown event | {"ok": false} |
405 Method Not Allowed | Non-POST requests | "PWV Deal Webhook - Method not allowed" |
500 Internal Server Error | Missing environment variables | {"ok": false, "error": "Server configuration error"} |
# Netlify webhook signing secret (JWS) # Find this in Netlify: Site Settings > Build & Deploy > Environment > Forms > Webhook secret NETLIFY_FORM_JWS=your_netlify_jws_secret # Slack webhook URL for notifications SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
The webhook expects form submissions with the following fields:
name- Applicant's nameemail- Applicant's email address (required)company- Company namewebsite- Company website URLdeck- Link to pitch deckmessage- Additional message/notes
graph TD A[Netlify Form Submitted] --> B[Webhook Receives POST] B --> C[Verify Headers Present] C --> D[Extract JWT Token] D --> E[Hash Request Body SHA-256] E --> F[Decode JWT Payload] F --> G[Compare Body Hash with JWT Hash] G --> H[Verify JWT Signature HMAC-SHA256] H --> I[Check Event Type] I --> J[Parse Form Data] J --> K[Validate Required Fields] K --> L[Check for Duplicates] L --> M[Store in Blob Storage] M --> N[Send Email Notification] N --> O[Send Slack Notification] O --> P[Return Success Response]
- Key Format:
pwv-fund-submission-{submission_id} - Storage: Val Town blob storage (JSON format)
- Deduplication: Prevents duplicate processing of the same submission
- Recipient:
dt@prestonwernerventures.com - Subject:
PWV Fund - New Submission - {company_name} - Format: Structured text with all form fields and extracted domain
- Channel: Configured via
SLACK_WEBHOOK_URL - Format: Formatted message with submission details and edit link
POST /
| Header | Required | Description |
|---|---|---|
x-netlify-event | Yes | Event type (must be "submission_created") |
x-webhook-signature | Yes | JWT token signed with HMAC-SHA256 |
Content-Type | Yes | application/json |
{ "id": "submission_unique_id", "number": 95, "data": { "name": "John Doe", "email": "john@example.com", "company": "Example Corp", "website": "https://example.com", "deck": "https://deck-link.com", "message": "We're building the future..." }, "created_at": "2025-12-22T17:31:35.939Z", "site_name": "pwv-www", "form_name": "apply" }
{ "ok": true }
{ "ok": true }
{ "ok": false, "error": "Invalid signature" }
The test-webhook.ts file provides an automated way to test the webhook with proper JWT signature generation:
// Run the test script
import test from "./test-webhook.ts";
What the test script does:
- ✅ Uses real sample data from production form submissions
- ✅ Calculates SHA-256 hash of the request body
- ✅ Generates a valid JWT token signed with your
NETLIFY_FORM_JWSsecret - ✅ Includes all required headers (
x-netlify-event,x-webhook-signature) - ✅ Sends POST request to the webhook endpoint
- ✅ Displays detailed results with status, body, and pass/fail indication
Test output example:
🧪 Testing PWV Fund Application Webhook
============================================================
📤 Sending POST request to webhook endpoint...
with x-netlify-event: submission_created
with JWT signature in x-webhook-signature header
📥 Response received:
Status: 200
Body: {"ok":true}
✅ TEST PASSED: Webhook accepted the signed request
Running the test:
- Click "Run" button in the Val Town editor
- Or execute via module import in another val
- Test will automatically run when the file is loaded
Important Notes:
- The test uses the same sample submission ID each time
- Second run will detect duplicate and return early (expected behavior)
- To test multiple times, change the
idfield in the sample data
For manual testing, you need to generate a valid JWT signature:
# This won't work - signature needs to be properly generated curl -X POST https://your-webhook-url.web.val.run \ -H "Content-Type: application/json" \ -H "x-netlify-event: submission_created" \ -H "x-webhook-signature: <VALID_JWT_TOKEN>" \ -d '{"id":"test","data":{...}}'
Note: Manual testing requires generating a valid JWT token. Use the test-webhook.ts script instead for accurate testing.
To test the webhook logic without signature verification during development:
- Temporarily comment out the signature verification in
main.ts - Send requests without the
x-webhook-signatureheader - Important: Always re-enable signature verification before deploying
- Logs: All requests and errors are logged with detailed context
- Request History: Use Val Town's request history to debug issues
- Error Tracking: Comprehensive error messages for troubleshooting
- Test Script: Regular testing with
test-webhook.tsto verify functionality
- Set Environment Variables: Configure
NETLIFY_FORM_JWSandSLACK_WEBHOOK_URL - Deploy to Val Town: The webhook is automatically deployed as an HTTP endpoint
- Configure Netlify:
- Navigate to Site Settings > Build & Deploy > Forms
- Add your Val Town webhook URL
- Generate and save the webhook secret to
NETLIFY_FORM_JWS
- Test: Run
test-webhook.tsto verify the integration
- Secret Rotation: Regularly rotate the
NETLIFY_FORM_JWSsecret- Generate new secret in Netlify
- Update environment variable in Val Town
- Update
test-webhook.tswith new secret
- HTTPS Only: Webhook only accepts HTTPS requests
- JWT Verification: All requests must have valid JWT signatures
- Body Integrity: SHA-256 hash verification prevents tampering
- Input Validation: All form data is validated before processing
- Error Handling: Sensitive information is not exposed in error messages
-
401 Unauthorized - Invalid signature
- Cause: JWT signature verification failed
- Solutions:
- Verify
NETLIFY_FORM_JWSmatches the secret in Netlify - Check that JWT token is correctly formatted
- Ensure request body hasn't been modified
- Run
test-webhook.tsto verify your secret is correct
- Verify
-
401 Unauthorized - Body hash mismatch
- Cause: Request body doesn't match the hash in JWT payload
- Solutions:
- Check for middleware modifying the request body
- Ensure content encoding is correct
- Verify no proxy is altering the request
-
Missing Headers
- Cause: Required headers not present
- Solutions:
- Ensure Netlify is configured to send webhooks
- Check webhook configuration in Netlify settings
- Verify event type is "submission_created"
-
500 Errors
- Cause: Missing environment variables
- Solutions:
- Verify
NETLIFY_FORM_JWSis set - Verify
SLACK_WEBHOOK_URLis set - Check Val Town environment variables
- Verify
-
Email/Slack Failures
- Cause: Service configuration issues
- Solutions:
- Check respective service configurations
- Verify credentials and permissions
- Review service logs for specific errors
- Check Val Town request logs for detailed error information
- Run the test script (
test-webhook.ts) to isolate issues - Verify JWT signature using online JWT decoder (jwt.io)
- Compare hashes - decode JWT and check if SHA-256 hash matches body
- Test with minimal payload to isolate issues
- Verify environment variables are properly set
- Check Netlify webhook settings for correct URL and secret
Use this checklist to debug JWT verification problems:
- JWT token is present in
x-webhook-signatureheader - JWT has three parts separated by dots (header.payload.signature)
- JWT payload contains
sha256field - Request body hash matches
sha256in JWT payload -
NETLIFY_FORM_JWSenvironment variable is set correctly - Secret matches the one configured in Netlify
- JWT signature algorithm is HS256 (HMAC-SHA256)
main.ts- Main webhook handler with JWT verificationtest-webhook.ts- Automated test script with JWT signingREADME.md- This documentation
This project is part of Preston-Werner Ventures' internal tooling.