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.
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
Why JWT for Webhooks?
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
NETLIFY_FORM_JWS)x-webhook-signature headerWebhook 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
| 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/notesgraph 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]
pwv-fund-submission-{submission_id}dt@prestonwernerventures.comPWV Fund - New Submission - {company_name}SLACK_WEBHOOK_URLPOST /
| 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:
NETLIFY_FORM_JWS secret using HMAC-SHA256Content-Type: application/jsonx-netlify-event: submission_createdx-webhook-signature: <JWT_TOKEN>JWT Generation Process in Test Script:
The test script replicates Netlify's exact JWT signing process:
// 1. Hash the request body with SHA-256
// This creates a fingerprint of the exact 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("");
// Result: "a1b2c3..." (64 hex characters)
// 2. Create JWT header and payload
const header = { alg: "HS256", typ: "JWT" };
const payload = {
iss: "netlify",
sha256: bodyHash, // ⬅️ The body hash ensures integrity
iat: Math.floor(Date.now() / 1000) // Optional: issued-at timestamp
};
// 3. Encode header and payload as base64url (not standard base64!)
// base64url uses - and _ instead of + and /, with no padding
const headerB64 = base64UrlEncode(JSON.stringify(header));
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
// 4. Sign with HMAC-SHA256 using the shared secret
// Import the secret as a cryptographic key
const cryptoKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret), // NETLIFY_FORM_JWS
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
// Create signature over "header.payload"
const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
new TextEncoder().encode(`${headerB64}.${payloadB64}`)
);
// 5. Encode signature and create final JWT token
const signatureB64 = base64UrlEncode(signature);
const jwtToken = `${headerB64}.${payloadB64}.${signatureB64}`;
// Result: "eyJ...header...eyJ...payload...sig..."
Key Points:
header.payload (the first two parts)Test Output Examples:
✅ Successful Test (First Run):
🧪 Testing PWV Fund Application Webhook
============================================================
🔑 JWT Token Generated:
Header: {"alg":"HS256","typ":"JWT"}
Payload: {"iss":"netlify","sha256":"abc123...","iat":1703...}
Signature: Valid HMAC-SHA256
📤 Sending POST request to webhook endpoint...
with x-netlify-event: submission_created
with x-webhook-signature header (JWT)
Body hash: abc123... (64 chars)
📥 Response received:
Status: 200
Body: {"ok":true}
✅ TEST PASSED: Webhook accepted the signed request
✓ JWT signature verified
✓ Body hash matched
✓ Submission processed
✓ Email sent
✓ Slack notified
✅ Successful Test (Second Run - Duplicate):
🧪 Testing PWV Fund Application Webhook
============================================================
📤 Sending POST request to webhook endpoint...
📥 Response received:
Status: 200
Body: {"ok":true}
✅ TEST PASSED: Webhook accepted the signed request
ℹ️ Note: Duplicate submission detected (expected behavior)
❌ Failed Test (Invalid Secret):
🧪 Testing PWV Fund Application Webhook
============================================================
📤 Sending POST request to webhook endpoint...
📥 Response received:
Status: 401
Body: {"ok":false,"error":"Invalid signature"}
❌ TEST FAILED: Webhook rejected the request
✗ JWT signature verification failed
→ Check that NETLIFY_FORM_JWS matches Netlify settings
Running the test:
test-webhook.tsExpected Results:
| Run | Expected Behavior | Status | Why |
|---|---|---|---|
| First run | Processes submission fully | ✅ 200 | New submission ID |
| Second run | Detects duplicate, returns early | ✅ 200 | Duplicate detection works |
| Wrong secret | Rejects with "Invalid signature" | ❌ 401 | HMAC verification fails |
| Modified body after signing | Rejects with "Invalid signature" | ❌ 401 | Body hash mismatch detected |
| Missing signature header | Rejects with error | ❌ 401 | Required header missing |
| Wrong event type | Returns early | ⚠️ 400 | Not "submission_created" |
Important Notes:
69498077f82665c79f3c0775) each timeid field in the sampleData objectCustomizing Test Data:
To test with different submissions, modify the sampleData object in test-webhook.ts:
const sampleData = {
id: "69498077f82665c79f3c0775", // ⬅️ Change this for each unique test
number: 95,
data: {
name: "Your Name",
email: "your@email.com",
company: "Your Company",
website: "https://example.com",
deck: "https://deck-link.com",
message: "Test message"
},
created_at: new Date().toISOString(),
// ... other fields
};
Pro Tip: Generate unique IDs for testing:
const uniqueId = crypto.randomUUID().replace(/-/g, "").substring(0, 24);
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:
main.tsx-webhook-signature headertest-webhook.ts to verify functionalityNETLIFY_FORM_JWS and SLACK_WEBHOOK_URLNETLIFY_FORM_JWStest-webhook.ts to verify the integrationNETLIFY_FORM_JWS secret
test-webhook.ts with new secret401 Unauthorized - Invalid signature
NETLIFY_FORM_JWS matches the secret in Netlifytest-webhook.ts to verify your secret is correct401 Unauthorized - Body hash mismatch
Missing Headers
500 Errors
NETLIFY_FORM_JWS is setSLACK_WEBHOOK_URL is setEmail/Slack Failures
test-webhook.ts) to isolate issuesUse this comprehensive checklist to debug JWT verification problems:
Header Checks:
x-webhook-signature headerx-netlify-event header is set to submission_createdContent-Type header is application/jsonJWT Structure:
sha256 field with 64 hex charactersBody Hash Verification:
sha256 in JWT payloadSecret Configuration:
NETLIFY_FORM_JWS environment variable is set correctlySignature Verification:
Debugging Tools:
test-webhook.ts to verify your secret and setupsha256 field| 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:
Key Functions:
verifyNetlifySignature() - JWT verification logicbase64UrlToArrayBuffer() - Base64url decoding for JWTAn automated test script that:
Key Functions:
base64UrlEncode() - Encodes data to base64url formatgenerateNetlifyJWT() - Creates valid JWT tokens with HMAC-SHA256 signaturesWhy This Test is Important:
This project is part of Preston-Werner Ventures' internal tooling.