• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
pwv

pwv

pwv-fund-application

Handles PWV Fund form web hook
Public
Like
pwv-fund-application
Home
Code
6
PROMPT.txt
README.md
agent.ts
H
main.ts
test-agent.ts
test-webhook.ts
Connections
Environment variables
3
Branches
2
Pull requests
Remixes
2
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
README.md
Code
/
README.md
Search
12/22/2025
Viewing readonly version of main branch: v135
View latest version
README.md

PWV Fund Application Webhook

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.

Features

  • ✅ 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

Security

JWT (JWS) Signature Verification

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.

What is JWT/JWS?

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).

How JWT Verification Works

  1. 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-signature header
  2. 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 }
  3. 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

Verification Steps

StepActionSecurity CheckWhat It Prevents
1Extract JWT from x-webhook-signature headerHeader presenceUnsigned requests
2Calculate SHA-256 hash of request bodyBody integrityModified payloads
3Decode JWT payloadJWT format validationMalformed tokens
4Compare body hash with JWT payload hashPayload matchingTampered bodies
5Verify JWT signature with HMAC-SHA256Cryptographic verificationForged signatures

Error Responses

Status CodeConditionResponse
401 UnauthorizedInvalid or missing signature{"ok": false, "error": "Invalid signature"}
400 Bad RequestMissing headers or unknown event{"ok": false}
405 Method Not AllowedNon-POST requests"PWV Deal Webhook - Method not allowed"
500 Internal Server ErrorMissing environment variables{"ok": false, "error": "Server configuration error"}

Configuration

Required Environment Variables

# 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/...

Netlify Form Setup

The webhook expects form submissions with the following fields:

  • name - Applicant's name
  • email - Applicant's email address (required)
  • company - Company name
  • website - Company website URL
  • deck - Link to pitch deck
  • message - Additional message/notes

Workflow

1. Form Submission Processing

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]

2. Data Storage

  • Key Format: pwv-fund-submission-{submission_id}
  • Storage: Val Town blob storage (JSON format)
  • Deduplication: Prevents duplicate processing of the same submission

3. Notifications

Email Notification

  • Recipient: dt@prestonwernerventures.com
  • Subject: PWV Fund - New Submission - {company_name}
  • Format: Structured text with all form fields and extracted domain

Slack Notification

  • Channel: Configured via SLACK_WEBHOOK_URL
  • Format: Formatted message with submission details and edit link

API Reference

Endpoint

POST /

Headers

HeaderRequiredDescription
x-netlify-eventYesEvent type (must be "submission_created")
x-webhook-signatureYesJWT token signed with HMAC-SHA256
Content-TypeYesapplication/json

Request Body

{ "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" }

Response Examples

Success

{ "ok": true }

Duplicate Submission

{ "ok": true }

Invalid Signature

{ "ok": false, "error": "Invalid signature" }

Development

Testing

Automated Test Script

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:

  1. ✅ Uses real sample data from production form submissions
  2. ✅ Calculates SHA-256 hash of the request body (same as Netlify)
  3. ✅ Creates JWT payload with the body hash
  4. ✅ Generates valid JWT token signed with your NETLIFY_FORM_JWS secret using HMAC-SHA256
  5. ✅ Includes all required headers:
    • Content-Type: application/json
    • x-netlify-event: submission_created
    • x-webhook-signature: <JWT_TOKEN>
  6. ✅ Sends POST request to the webhook endpoint
  7. ✅ Displays detailed results with status, body, and pass/fail indication

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:

  • Uses base64url encoding (not regular base64) for URL safety
  • Signature is created over header.payload (the first two parts)
  • HMAC-SHA256 ensures only parties with the secret can sign
  • The body hash in the payload links the JWT to the specific request body

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:

  • 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:

RunExpected BehaviorStatusWhy
First runProcesses submission fully✅ 200New submission ID
Second runDetects duplicate, returns early✅ 200Duplicate detection works
Wrong secretRejects with "Invalid signature"❌ 401HMAC verification fails
Modified body after signingRejects with "Invalid signature"❌ 401Body hash mismatch detected
Missing signature headerRejects with error❌ 401Required header missing
Wrong event typeReturns early⚠️ 400Not "submission_created"

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 id field in the sampleData object
  • The test proves that your JWT verification is working correctly
  • Use this test after any changes to verify the webhook still works
  • Test generates authentic JWTs using the same algorithm (HMAC-SHA256) as Netlify
  • Safe to run repeatedly - duplicate detection prevents duplicate processing

Customizing 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);

Manual Testing with cURL

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.

Testing Without Signature (For Development)

To test the webhook logic without signature verification during development:

  1. Temporarily comment out the signature verification in main.ts
  2. Send requests without the x-webhook-signature header
  3. Important: Always re-enable signature verification before deploying

Monitoring

  • 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.ts to verify functionality

Deployment

  1. Set Environment Variables: Configure NETLIFY_FORM_JWS and SLACK_WEBHOOK_URL
  2. Deploy to Val Town: The webhook is automatically deployed as an HTTP endpoint
  3. 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
  4. Test: Run test-webhook.ts to verify the integration

Security Considerations

  • Secret Rotation: Regularly rotate the NETLIFY_FORM_JWS secret
    • Generate new secret in Netlify
    • Update environment variable in Val Town
    • Update test-webhook.ts with 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

Troubleshooting

Common Issues

  1. 401 Unauthorized - Invalid signature

    • Cause: JWT signature verification failed
    • Solutions:
      • Verify NETLIFY_FORM_JWS matches the secret in Netlify
      • Check that JWT token is correctly formatted
      • Ensure request body hasn't been modified
      • Run test-webhook.ts to verify your secret is correct
  2. 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
  3. 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"
  4. 500 Errors

    • Cause: Missing environment variables
    • Solutions:
      • Verify NETLIFY_FORM_JWS is set
      • Verify SLACK_WEBHOOK_URL is set
      • Check Val Town environment variables
  5. Email/Slack Failures

    • Cause: Service configuration issues
    • Solutions:
      • Check respective service configurations
      • Verify credentials and permissions
      • Review service logs for specific errors

Debug Steps

  1. Check Val Town request logs for detailed error information
  2. Run the test script (test-webhook.ts) to isolate issues
  3. Verify JWT signature using online JWT decoder (jwt.io)
  4. Compare hashes - decode JWT and check if SHA-256 hash matches body
  5. Test with minimal payload to isolate issues
  6. Verify environment variables are properly set
  7. Check Netlify webhook settings for correct URL and secret

Debugging JWT Issues

Use this comprehensive checklist to debug JWT verification problems:

Header Checks:

  • JWT token is present in x-webhook-signature header
  • x-netlify-event header is set to submission_created
  • Content-Type header is application/json

JWT Structure:

  • JWT has three parts separated by dots (header.payload.signature)
  • Each part is valid base64url encoded (no + or / or = characters)
  • JWT payload contains sha256 field with 64 hex characters

Body Hash Verification:

  • Request body hash matches sha256 in JWT payload
  • Body hasn't been modified after signature was created
  • No middleware is altering the request body

Secret Configuration:

  • NETLIFY_FORM_JWS environment variable is set correctly
  • Secret matches the one configured in Netlify settings
  • No whitespace or newlines in the secret value

Signature Verification:

  • JWT signature algorithm is HS256 (HMAC-SHA256)
  • Signature verification is using the correct key
  • Base64url decoding is working correctly (not standard base64)

Debugging Tools:

  1. Decode JWT at jwt.io to inspect header and payload
  2. Run test-webhook.ts to verify your secret and setup
  3. Check Val Town logs for detailed error messages
  4. Compare the body hash in logs with JWT payload sha256 field

Test Script Troubleshooting

IssueSymptomSolution
Test returns 401{"ok": false, "error": "Invalid signature"}1. Verify NETLIFY_FORM_JWS env var is set correctly
2. Check secret matches Netlify
3. Ensure no typos in secret
Test returns 400{"ok": false}1. Verify x-netlify-event header is set
2. 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 var
2. Check webhook has SLACK_WEBHOOK_URL env var
3. Verify both vals have access to env vars
Test hangsNo response1. Check network connectivity
2. Verify webhook endpoint URL
3. Check for Val Town service issues
Second run failsExpected: should succeed with duplicate detectionThis is normal behavior - webhook detects duplicate submission ID
Hash mismatchBody hash doesn't match JWT payload1. 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

Files

FileTypePurposeKey Features
main.tsHTTP handlerMain webhook endpointJWT verification, signature validation, data processing, notifications
test-webhook.tsScriptAutomated test suiteJWT signing, signature generation, end-to-end testing
README.mdDocumentationProject documentationSetup guide, security details, troubleshooting

File Details

main.ts

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 logic
  • base64UrlToArrayBuffer() - Base64url decoding for JWT
  • Main HTTP handler - Request processing and orchestration

test-webhook.ts

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 format
  • generateNetlifyJWT() - 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

License

This project is part of Preston-Werner Ventures' internal tooling.

FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.