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.
JWT (JSON Web Token) is a standard (RFC 7519) for securely transmitting information between parties as a digitally signed token. JWS (JSON Web Signature, RFC 7515) is the signature component that ensures the token hasn't been tampered with.
JWT Structure: header.payload.signature
- Header: Metadata about the token (algorithm: HS256, type: JWT)
- Payload: The actual data (contains SHA-256 hash of request body and optional metadata)
- Signature: Cryptographic signature created using HMAC-SHA256 and a shared secret key
Why JWT for Webhooks?
- Tamper-proof: Any modification to the body or headers invalidates the signature
- Verifiable: Only parties with the shared secret can create valid signatures
- Standard: Uses well-established cryptographic primitives (HMAC-SHA256)
- Efficient: Verification is fast and doesn't require database lookups
Example JWT Token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaGEyNTYiOiJhYmMxMjMuLi4iLCJpYXQiOjE3MDM...signature...
You can decode JWTs at jwt.io to inspect the header and payload (but signature verification requires the secret).
-
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); const bodyHashHex = Array.from(new Uint8Array(bodyHash)) .map(b => b.toString(16).padStart(2, "0")) .join(""); // 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 !== bodyHashHex) { return 401; // Body was tampered with } // 5. Import the shared secret as a cryptographic key const encoder = new TextEncoder(); const keyData = encoder.encode(NETLIFY_FORM_JWS); const cryptoKey = await crypto.subtle.importKey( "raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"] ); // 6. Verify JWT signature using shared secret const dataToVerify = encoder.encode(`${headerB64}.${payloadB64}`); const signatureBytes = base64UrlToArrayBuffer(signatureB64); const isValid = await crypto.subtle.verify( "HMAC", cryptoKey, signatureBytes, dataToVerify ); if (!isValid) { return 401; // Invalid signature } -
Security Guarantees
- Authenticity: Only requests signed with your secret are accepted
- Integrity: Any tampering with the request body is detected via hash mismatch
- Non-repudiation: The signature proves the request came from Netlify
- Replay Protection: Combined with deduplication (checking submission ID), prevents replay attacks
- No Secrets Exposed: The secret never leaves Netlify/Val Town servers
| Step | Action | Security Check | What It Prevents |
|---|---|---|---|
| 1 | Extract JWT from x-webhook-signature header | Header presence | Unsigned requests |
| 2 | Calculate SHA-256 hash of request body | Body integrity | Modified payloads |
| 3 | Decode JWT payload | JWT format validation | Malformed tokens |
| 4 | Compare body hash with JWT payload hash | Payload matching | Tampered bodies |
| 5 | Verify JWT signature with HMAC-SHA256 | Cryptographic verification | Forged signatures |
| 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, mimicking exactly how Netlify signs and sends webhook requests.
// 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 (same as Netlify)
- ✅ Creates JWT payload with the body hash
- ✅ Generates valid JWT token signed with your
NETLIFY_FORM_JWSsecret using HMAC-SHA256 - ✅ Includes all required headers:
Content-Type: application/jsonx-netlify-event: submission_createdx-webhook-signature: <JWT_TOKEN>
- ✅ Sends POST request to the webhook endpoint
- ✅ Displays detailed results with status, body, and pass/fail indication
JWT Generation Process in Test Script:
// 1. Hash the request body
const bodyBytes = new TextEncoder().encode(JSON.stringify(sampleData));
const hashBuffer = await crypto.subtle.digest("SHA-256", bodyBytes);
const bodyHash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0"))
.join("");
// 2. Create JWT header and payload
const header = { alg: "HS256", typ: "JWT" };
const payload = {
iss: "netlify",
sha256: bodyHash, // Include the body hash
};
// 3. Encode header and payload as base64url
const headerB64 = base64UrlEncode(JSON.stringify(header));
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
// 4. Sign with HMAC-SHA256
const cryptoKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
new TextEncoder().encode(`${headerB64}.${payloadB64}`)
);
// 5. Create final JWT token
const signatureB64 = base64UrlEncode(signature);
const jwtToken = `${headerB64}.${payloadB64}.${signatureB64}`;
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:
- Option 1: Click "Run" button in the Val Town editor for
test-webhook.ts - Option 2: Execute via module import in another val
- Option 3: Test will automatically run when the file is loaded
Expected Results:
| Run | Expected Behavior | Why |
|---|---|---|
| First run | ✅ Status 200, processes submission | New submission ID |
| Second run | ✅ Status 200, returns early | Duplicate detection works |
| Wrong secret | ❌ Status 401, invalid signature | Security verification works |
| Modified body | ❌ Status 401, body hash mismatch | Integrity verification works |
Important Notes:
- The test uses the same sample submission ID (
69498077f82665c79f3c0775) each time - Second run will detect duplicate and return early (this is expected behavior)
- To test multiple submissions, change the
idfield in thesampleDataobject - The test proves that your JWT verification is working correctly
- Use this test after any changes to verify the webhook still works
Customizing Test Data:
To test with different data, modify the sampleData object in test-webhook.ts:
const sampleData = {
id: "unique_id_here", // Change this for each test
data: {
name: "Your Name",
email: "your@email.com",
company: "Your Company",
// ... other fields
}
};
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)
| Issue | Symptom | Solution |
|---|---|---|
| Test returns 401 | {"ok": false, "error": "Invalid signature"} | 1. Verify NETLIFY_FORM_JWS env var is set correctly2. Check secret matches Netlify 3. Ensure no typos in secret |
| Test returns 400 | {"ok": false} | 1. Verify x-netlify-event header is set2. Check request format is correct 3. Ensure body is valid JSON |
| Test returns 500 | {"ok": false, "error": "Server configuration error"} | 1. Check webhook has NETLIFY_FORM_JWS env var2. Check webhook has SLACK_WEBHOOK_URL env var3. Verify both vals have access to env vars |
| Test hangs | No response | 1. Check network connectivity 2. Verify webhook endpoint URL 3. Check for Val Town service issues |
| Second run fails | Expected: should succeed with duplicate detection | This is normal behavior - webhook detects duplicate submission ID |
| Hash mismatch | Body hash doesn't match JWT payload | 1. Ensure body isn't being modified 2. Check encoding is consistent (UTF-8) 3. Verify no whitespace changes |
Testing the Test Script:
# Verify JWT is being generated correctly # Decode the JWT at https://jwt.io to inspect: # - Header should have: {"alg":"HS256","typ":"JWT"} # - Payload should have: {"iss":"netlify","sha256":"..."} # - Signature should verify with your NETLIFY_FORM_JWS secret
| File | Type | Purpose | Key Features |
|---|---|---|---|
main.ts | HTTP handler | Main webhook endpoint | JWT verification, signature validation, data processing, notifications |
test-webhook.ts | Script | Automated test suite | JWT signing, signature generation, end-to-end testing |
README.md | Documentation | Project documentation | Setup guide, security details, troubleshooting |
The main webhook handler that:
- Receives POST requests from Netlify
- Verifies JWT signatures using HMAC-SHA256
- Validates request body integrity via SHA-256 hash comparison
- Processes form submissions
- Stores data in blob storage with deduplication
- Sends email and Slack notifications
Key Functions:
verifyNetlifySignature()- JWT verification logicbase64UrlToArrayBuffer()- Base64url decoding for JWT- Main HTTP handler - Request processing and orchestration
An automated test script that:
- Generates valid JWT signatures matching Netlify's implementation
- Creates test requests with proper headers
- Verifies the webhook's security and functionality
- Provides detailed test results
Key Functions:
base64UrlEncode()- Encodes data to base64url formatgenerateNetlifyJWT()- Creates valid JWT tokens with HMAC-SHA256 signatures- Main test function - Executes the test and reports results
Why This Test is Important:
- Validates JWT signing/verification is working correctly
- Ensures webhook security is properly configured
- Proves the integration with Netlify will work
- Catches configuration errors before production
This project is part of Preston-Werner Ventures' internal tooling.