• 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
3
README.md
H
main.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: v100
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:

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

RunExpected BehaviorWhy
First run✅ Status 200, processes submissionNew submission ID
Second run✅ Status 200, returns earlyDuplicate detection works
Wrong secret❌ Status 401, invalid signatureSecurity verification works
Modified body❌ Status 401, body hash mismatchIntegrity 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 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

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 } };

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 checklist to debug JWT verification problems:

  • JWT token is present in x-webhook-signature header
  • JWT has three parts separated by dots (header.payload.signature)
  • JWT payload contains sha256 field
  • Request body hash matches sha256 in JWT payload
  • NETLIFY_FORM_JWS environment variable is set correctly
  • Secret matches the one configured in Netlify
  • JWT signature algorithm is HS256 (HMAC-SHA256)

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.