Tutorial: Testing Webhooks with Jetty (Stripe Example)
Overview
This interactive tutorial walks you through testing webhooks locally using Jetty, with Stripe as a real-world example. You'll learn how to receive live webhook payloads on your local machine, inspect them for debugging, replay them for testing, and implement proper security verification.
Estimated time: 8-10 minutes
Difficulty: Intermediate
What you'll learn:
- Reserve a stable subdomain for webhook testing
- Configure Stripe webhooks with your Jetty tunnel URL
- Receive and inspect real webhook payloads locally
- Use the Traffic Inspector to debug webhook issues
- Replay webhooks to test different scenarios
- Verify webhook signatures for security
Prerequisites
Before starting this tutorial, you should have:
- Jetty CLI installed (installation guide)
- Jetty CLI authenticated with an API token
- A local application running (or ready to start)
- A Stripe account (free test account works perfectly)
- Basic understanding of webhooks and HTTP requests
Why This Matters
Traditional webhook development is slow and painful:
- Write webhook handler code
- Deploy to staging server
- Configure webhook in provider
- Trigger test event
- Check remote logs
- Find bug, fix code
- Deploy again... repeat endlessly
With Jetty, your workflow becomes:
- Write webhook handler code
- Run
jetty sharewith a reserved subdomain - Configure webhook once (URL never changes)
- Trigger test event
- See full payload in Traffic Inspector
- Fix bug and replay webhook instantly
- Done!
The difference is massive - no deployments, no remote logs, instant feedback.
Step 1: Reserve a Subdomain for Webhooks
Why Reserve a Subdomain?
When testing webhooks, you need a URL that stays consistent across development sessions. Random tunnel URLs mean reconfiguring your webhook endpoint every time you restart your server. Reserved subdomains give you the same URL every time.
Benefits:
- Same URL every session - configure webhooks once
- Memorable names -
stripe-webhooksvsae3f91c - Organized - different subdomains for different services
- Team-friendly - colleagues know which tunnel is which
How to Reserve
Reserving a subdomain is automatic - just use the --subdomain flag:
jetty share 3000 --subdomain=stripe-webhooks
What happens:
- Jetty checks if
stripe-webhooksis available for your organization - If available, it's automatically reserved for you
- You get
https://stripe-webhooks.tunnels.usejetty.online - This URL is now yours to use anytime
Choosing a Good Subdomain Name
Good names:
stripe-webhooks- Clear purposemyapp-stripe-dev- Includes app contextalice-payment-hooks- Personal dev environmentstaging-stripe- Environment-specific
Avoid:
test- Too generic, likely takenwebhooks- Vague, what kind of webhooks?a,dev,temp- Not descriptive
What if My Name is Taken?
Subdomains are reserved per organization. If stripe-webhooks is taken by a teammate, try:
- Adding your name:
stripe-webhooks-alice - Adding a project name:
myapp-stripe-webhooks - Being more specific:
stripe-payment-intents
Pro tip: You can reserve multiple subdomains for different purposes. Create one for Stripe, one for GitHub, one for Shopify, etc.
Step 2: Start Your Local Application
Prepare Your Webhook Handler
Before sharing a tunnel, you need a local server running to receive webhooks. This could be any HTTP server:
Node.js/Express:
npm run dev
# or
node server.js
Python/Flask:
flask run --port=3000
# or
python app.py
Ruby/Rails:
rails server -p 3000
PHP/Laravel:
php artisan serve --port=3000
Go:
go run main.go
Webhook Endpoint Requirements
Your application should have an endpoint that accepts POST requests. For Stripe, this is typically something like:
/webhooks/stripe/api/webhooks/stripe/stripe/webhook
The exact path doesn't matter - just remember it for configuring Stripe later.
Simple Test Handler
Don't have a webhook handler yet? Here's a minimal example to get started:
Node.js/Express:
const express = require('express');
const app = express();
// IMPORTANT: Use express.raw() for webhook routes to get raw body
app.post('/webhooks/stripe',
express.raw({type: 'application/json'}),
(req, res) => {
console.log('Webhook received!');
console.log('Headers:', req.headers);
console.log('Body:', req.body.toString());
res.json({received: true});
}
);
app.listen(3000, () => {
console.log('Server listening on port 3000');
});
Python/Flask:
from flask import Flask, request, jsonify
import json
app = Flask(__name__)
@app.route('/webhooks/stripe', methods=['POST'])
def webhook():
print('Webhook received!')
print('Headers:', dict(request.headers))
print('Body:', request.data.decode('utf-8'))
return jsonify({'received': True}), 200
if __name__ == '__main__':
app.run(port=3000)
PHP/Laravel:
Route::post('/webhooks/stripe', function (Request $request) {
Log::info('Webhook received!');
Log::info('Headers: ' . json_encode($request->headers->all()));
Log::info('Body: ' . $request->getContent());
return response()->json(['received' => true]);
});
Verify Your Server is Running
Before continuing, verify your server is actually running:
# In a new terminal, test your endpoint
curl -X POST http://localhost:3000/webhooks/stripe -d '{"test": true}'
You should see a response like {"received": true} and a log message in your server output.
Note Your Port Number
Remember which port your server is running on (e.g., 3000, 8000, 8080). You'll need this in the next step.
Step 3: Share Your Tunnel with Reserved Subdomain
Create the Tunnel
Now we'll connect Stripe to your local server with a public HTTPS tunnel:
jetty share 3000 --subdomain=stripe-webhooks
Replace 3000 with your server's port and stripe-webhooks with your chosen subdomain.
Expected Output
Subdomain reserved: stripe-webhooks
Tunnel created: https://stripe-webhooks.tunnels.usejetty.online
Forwarding traffic to http://localhost:3000
Press Ctrl+C to stop sharing
What Just Happened?
- Your subdomain was reserved (or confirmed if already reserved)
- A secure HTTPS tunnel was created
- All traffic to
https://stripe-webhooks.tunnels.usejetty.onlinenow forwards tohttp://localhost:3000 - The tunnel will stay active until you press Ctrl+C
Your Webhook URL
Your full webhook URL is:
https://stripe-webhooks.tunnels.usejetty.online/webhooks/stripe
- Base URL:
https://stripe-webhooks.tunnels.usejetty.online - Path:
/webhooks/stripe(your endpoint path)
Copy this URL - you'll need it for configuring Stripe in the next step.
Keep the Tunnel Running
Important: Keep this terminal window open! The tunnel stays active only while this command is running. If you close it or press Ctrl+C, the tunnel stops and Stripe won't be able to reach your local server.
Pro tip: Open a new terminal tab/window for the remaining steps so you can leave the tunnel running in the background.
Troubleshooting
Subdomain already reserved by another user:
Choose a different subdomain name. Subdomains are unique per organization.
jetty share 3000 --subdomain=stripe-webhooks-alice
Connection refused to localhost:
Make sure your local server is actually running (check Step 2). You should see server logs when you run jetty share.
Permission denied:
Make sure you're authenticated with the CLI:
jetty auth:login
Step 4: Configure Webhook in Stripe Dashboard
Access Stripe Webhooks
- Log in to your Stripe Dashboard
- Click Developers in the top navigation
- Select Webhooks from the left sidebar
- Click the Add endpoint button
Configure the Endpoint
Endpoint URL:
Paste your full Jetty tunnel URL including the webhook path:
https://stripe-webhooks.tunnels.usejetty.online/webhooks/stripe
Don't forget the path! Include /webhooks/stripe or whatever endpoint path your app uses.
Description: (Optional but recommended)
Local development - webhook testing with Jetty
Events to send:
For testing, you have two options:
Option 1: Select specific events (recommended for production)
Common webhook events to test:
payment_intent.succeeded- Payment completed successfullypayment_intent.payment_failed- Payment failedcharge.succeeded- Charge created successfullycharge.refunded- Refund processedcustomer.created- New customer createdcustomer.subscription.created- Subscription startedcustomer.subscription.deleted- Subscription cancelledinvoice.payment_succeeded- Invoice paidinvoice.payment_failed- Invoice payment failed
Option 2: Select all events (great for exploration)
Click "Select all events" to receive every webhook Stripe can send. This is perfect for learning what's available.
Important: Use Test Mode
Make sure you're in Test mode (toggle in the top right of the Stripe Dashboard). This ensures you're not triggering webhooks for real payments.
In test mode:
- No real money is involved
- Use test card numbers like
4242 4242 4242 4242 - Events are clearly marked as "test"
- Safe to experiment
Save the Endpoint
Click Add endpoint to save your configuration.
Stripe will show your new webhook endpoint with:
- Status: "Enabled"
- Signing secret:
whsec_...(we'll use this in Step 8) - Event list: All the events you selected
Verify Configuration
You should see your endpoint listed in the Webhooks dashboard. It might show "No events sent yet" - that's expected! We'll send test events in the next step.
Step 5: Trigger a Test Webhook from Stripe
Stripe provides several ways to trigger test webhooks. Let's explore each method.
Method 1: Send Test Webhook (Easiest)
Stripe's dashboard has a built-in test webhook feature:
- In Developers → Webhooks, click on your webhook endpoint
- Click the Send test webhook button
- Select an event type from the dropdown (e.g.,
payment_intent.succeeded) - Click Send test webhook
What you'll see:
- Stripe sends a test webhook with sample data
- Your tunnel logs show the incoming request
- Your local server processes the webhook
- The Stripe dashboard shows delivery status
This is the fastest way to test, but the data is generic sample data, not from a real test transaction.
Method 2: Use Stripe CLI (Most Flexible)
If you have the Stripe CLI installed, you can trigger any event instantly:
# Trigger a successful payment intent
stripe trigger payment_intent.succeeded
# Trigger a failed payment
stripe trigger payment_intent.payment_failed
# Trigger a refund
stripe trigger charge.refunded
# Trigger a subscription creation
stripe trigger customer.subscription.created
Advantages:
- Instant - no need to navigate the dashboard
- Realistic data - creates actual test objects
- Full control - trigger any event type
- Scriptable - automate testing scenarios
Install Stripe CLI:
# macOS
brew install stripe/stripe-cli/stripe
# Linux
# Download from https://github.com/stripe/stripe-cli/releases
# Windows
scoop install stripe
Authenticate:
stripe login
Method 3: Create Real Test Event (Most Realistic)
The most realistic way is to create an actual test payment flow:
- Use Stripe's test card numbers
- Create a payment through your app (or Stripe dashboard)
- Real webhooks are triggered just like in production
Test card numbers:
Success: 4242 4242 4242 4242
Requires 3DS: 4000 0027 6000 3184
Declined: 4000 0000 0000 0002
Insufficient: 4000 0000 0000 9995
Expiry: Any future date (e.g., 12/34)
CVC: Any 3 digits (e.g., 123)
ZIP: Any 5 digits (e.g., 12345)
To create a test payment:
- Go to Stripe Dashboard → Payments
- Click New → Payment
- Enter any amount (e.g., $10.00)
- Use test card:
4242 4242 4242 4242 - Complete the payment
This triggers real webhook events with actual payment data!
Watch Your Tunnel Logs
Whichever method you choose, watch the terminal where jetty share is running. You'll see real-time logs:
[2024-01-15 14:32:10] POST /webhooks/stripe - 200 OK (52ms)
And in your application logs:
Webhook received!
Event type: payment_intent.succeeded
Payment Intent ID: pi_3AbCdEfGhIjKlMnO
Verify Delivery in Stripe
Back in the Stripe Dashboard:
- Go to Developers → Webhooks
- Click on your endpoint
- Scroll down to Events
- You should see the webhook attempt with status "Succeeded" (200)
If you see "Failed" or "Pending", there's an issue. Check your tunnel logs and application logs for errors.
Step 6: Inspect Webhook Payload in Traffic Inspector
This is where Jetty really shines. The Traffic Inspector shows you everything about the webhook request - no logging code required!
Access Traffic Inspector
- Open the Jetty Dashboard
- Click Tunnels in the left sidebar
- Click on your tunnel (e.g.,
stripe-webhooks) - Open the Traffic tab
- Click on the most recent webhook request
What You'll See
The Traffic Inspector shows complete request details:
Request Line
POST /webhooks/stripe HTTP/1.1
Headers
Content-Type: application/json
Stripe-Signature: t=1705330330,v1=5257a8...
User-Agent: Stripe/1.0 (+https://stripe.com/docs/webhooks)
X-Stripe-Event-Id: evt_3AbCdEfGhIjKlMnO
Accept: */*
Content-Length: 1234
Key headers:
- Stripe-Signature: Used to verify authenticity (more in Step 8)
- X-Stripe-Event-Id: Unique event identifier for debugging
- Content-Type: Always
application/jsonfor Stripe
Request Body (Formatted JSON)
{
"id": "evt_3AbCdEfGhIjKlMnO",
"object": "event",
"api_version": "2023-10-16",
"created": 1705330330,
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_3AbCdEfGhIjKlMnO",
"object": "payment_intent",
"amount": 1000,
"currency": "usd",
"status": "succeeded",
"customer": "cus_AbCdEfGhIjKl",
"description": "Test payment",
"metadata": {},
"payment_method": "pm_1AbCdEfGhIjKlMnO",
"receipt_email": "customer@example.com"
}
},
"livemode": false,
"pending_webhooks": 1,
"request": {
"id": "req_AbCdEfGhIjKlMnO",
"idempotency_key": "abc123-def456-ghi789"
}
}
Response
Status: 200 OK
Body: {"received": true}
Duration: 52ms
Understanding the Stripe Event Structure
Every Stripe webhook follows this pattern:
{
"id": "evt_...", // Unique event ID
"type": "payment_intent.succeeded", // What happened
"data": {
"object": { /* The actual resource */ }
},
"created": 1234567890, // Unix timestamp
"livemode": false // Test mode = false
}
Key fields:
- id: Unique event identifier - use for idempotency
- type: Event name - what triggered this webhook
- data.object: The actual resource (payment intent, charge, customer, etc.)
- created: When the event occurred (Unix timestamp)
- livemode:
falsefor test mode,truefor production
Why This is Powerful
Without Jetty, to see this data you'd need to:
- Add logging code to your webhook handler
- Deploy to a server
- Trigger a webhook
- SSH into the server
- Dig through log files
- Find the right request
- Parse the logged data
With Jetty's Traffic Inspector:
- Trigger webhook
- Click on it in the dashboard
- See everything immediately
This changes everything for webhook debugging!
Copy Data for Testing
You can copy the entire request body to use in tests:
- Click the Copy button next to the request body
- Paste into your test fixtures or mock data
- Now you have real-world data for unit tests!
Step 7: Replay Webhook with Jetty Replay
Replay is Jetty's killer feature for webhook testing. Re-send any webhook instantly without triggering new events in Stripe.
Why Replay is Amazing
Traditional workflow when you find a bug:
- Find bug in webhook handler
- Fix the code
- Go back to Stripe dashboard
- Trigger a new test webhook
- Hope it works
- Repeat if it doesn't
With Jetty replay:
- Find bug in webhook handler
- Fix the code
- Click "Replay" button
- Done!
No context switching, no waiting, instant feedback.
How to Replay
- In the Traffic Inspector, select the webhook request you want to replay
- Click the Replay button (↻ icon) in the top right
- (Optional) Edit the request body or headers to test edge cases
- Click Send Replay
- Watch it deliver to your local server again
Advanced: Editing Replays
You can modify the webhook before replaying it to test edge cases:
Example: Test a large payment amount
Change:
"amount": 1000
To:
"amount": 999999
Example: Test a different currency
Change:
"currency": "usd"
To:
"currency": "eur"
Example: Test missing optional fields
Remove fields to test how your handler deals with missing data:
{
"amount": 1000,
// Removed "customer" field - what happens?
"status": "succeeded"
}
Detecting Replays in Your Code
Jetty adds a special header to replayed requests:
X-Jetty-Replay: true
You can use this to distinguish replays from real webhooks:
Node.js:
app.post('/webhooks/stripe', (req, res) => {
const isReplay = req.headers['x-jetty-replay'] === 'true';
if (isReplay) {
console.log('Replay detected - skipping signature verification');
}
// Your webhook logic here
});
Python:
@app.route('/webhooks/stripe', methods=['POST'])
def webhook():
is_replay = request.headers.get('X-Jetty-Replay') == 'true'
if is_replay:
print('Replay detected - skipping signature verification')
# Your webhook logic here
Replay Limitations
Stripe signatures are time-sensitive:
Replayed webhooks include the original Stripe-Signature header, which contains a timestamp. Stripe signatures are only valid for 5 minutes to prevent replay attacks.
Solution: When detecting a replay (via X-Jetty-Replay header), skip signature verification:
const isReplay = req.headers['x-jetty-replay'] === 'true';
if (!isReplay) {
// Verify signature for real webhooks
stripe.webhooks.constructEvent(req.body, signature, webhookSecret);
} else {
// Skip verification for replays
event = JSON.parse(req.body);
}
Database duplicates:
If your webhook handler creates database records, replaying the same webhook multiple times will create duplicates unless you implement idempotency checks.
Solution: Use the Stripe event ID for idempotency:
const eventId = event.id; // e.g., "evt_3AbCdEfGhIjKlMnO"
// Check if we've already processed this event
const exists = await db.webhookEvents.findOne({ stripeEventId: eventId });
if (exists) {
console.log('Event already processed, skipping');
return res.json({ received: true });
}
// Process the event and store the ID
await processWebhook(event);
await db.webhookEvents.create({ stripeEventId: eventId, processedAt: new Date() });
Replay Use Cases
1. Debugging:
Set a breakpoint in your code, replay the webhook, step through the execution.
2. Testing fixes:
Fix a bug, replay the problematic webhook, verify it works.
3. Load testing:
Replay the same webhook 100 times to test performance.
4. Edge cases:
Edit the payload to test rare scenarios (huge amounts, missing fields, etc.).
5. Team review:
Share a specific webhook with a colleague - they can replay it on their local machine.
Step 8: Verify Webhook Signatures (Security)
Webhook signature verification is critical for security. Without it, anyone could send fake webhooks to your endpoint.
Why Verify Signatures?
Without verification, attackers could:
- Send fake payment succeeded webhooks to unlock content
- Trigger unauthorized actions in your system
- Spam your endpoint with junk data
- Test for vulnerabilities
With verification:
- Only authentic Stripe webhooks are processed
- Tampered payloads are rejected
- Your system is protected from abuse
How Stripe Signatures Work
- Stripe creates a signature using your webhook signing secret
- The signature is sent in the
Stripe-Signatureheader - Your code re-computes the signature using the same secret
- If they match, the webhook is authentic
- If they don't match, reject it
Get Your Signing Secret
- In Stripe Dashboard, go to Developers → Webhooks
- Click on your webhook endpoint
- Under "Signing secret", click Reveal
- Copy the secret (starts with
whsec_...) - Add it to your
.envfile:
STRIPE_WEBHOOK_SECRET=whsec_1234567890abcdefghijklmnopqrstuvwxyz
Important: Each webhook endpoint has its own signing secret. Make sure you copy the right one!
Implementation Examples
Node.js/Express
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// IMPORTANT: Use express.raw() for webhook routes
// Signature verification requires the raw request body
app.post('/webhooks/stripe',
express.raw({type: 'application/json'}),
async (req, res) => {
const signature = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Check if this is a replay (optional: skip verification for replays)
const isReplay = req.headers['x-jetty-replay'] === 'true';
let event;
if (!isReplay) {
try {
// Verify the webhook signature
event = stripe.webhooks.constructEvent(
req.body,
signature,
webhookSecret
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
console.log('Webhook signature verified');
} else {
// For replays, parse the body without verification
event = JSON.parse(req.body);
console.log('Replay detected - skipping verification');
}
// Signature verified! Process the event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
console.log(`Payment succeeded: ${paymentIntent.id}`);
await handlePaymentSuccess(paymentIntent);
break;
case 'payment_intent.payment_failed':
const failedPayment = event.data.object;
console.log(`Payment failed: ${failedPayment.id}`);
await handlePaymentFailure(failedPayment);
break;
case 'customer.subscription.created':
const subscription = event.data.object;
console.log(`New subscription: ${subscription.id}`);
await handleSubscriptionCreated(subscription);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Return 200 to acknowledge receipt
res.json({received: true});
}
);
app.listen(3000, () => {
console.log('Server listening on port 3000');
});
Python/Flask
import os
import stripe
from flask import Flask, request, jsonify
app = Flask(__name__)
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
@app.route('/webhooks/stripe', methods=['POST'])
def webhook():
payload = request.data
sig_header = request.headers.get('Stripe-Signature')
webhook_secret = os.environ.get('STRIPE_WEBHOOK_SECRET')
# Check if this is a replay
is_replay = request.headers.get('X-Jetty-Replay') == 'true'
if not is_replay:
try:
# Verify the webhook signature
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
print('Webhook signature verified')
except ValueError as e:
# Invalid payload
print(f'Invalid payload: {e}')
return 'Invalid payload', 400
except stripe.error.SignatureVerificationError as e:
# Invalid signature
print(f'Invalid signature: {e}')
return 'Invalid signature', 400
else:
# For replays, parse without verification
event = stripe.Event.construct_from(
request.get_json(), stripe.api_key
)
print('Replay detected - skipping verification')
# Handle the event
if event.type == 'payment_intent.succeeded':
payment_intent = event.data.object
print(f'Payment succeeded: {payment_intent.id}')
handle_payment_success(payment_intent)
elif event.type == 'payment_intent.payment_failed':
payment_intent = event.data.object
print(f'Payment failed: {payment_intent.id}')
handle_payment_failure(payment_intent)
elif event.type == 'customer.subscription.created':
subscription = event.data.object
print(f'New subscription: {subscription.id}')
handle_subscription_created(subscription)
else:
print(f'Unhandled event type: {event.type}')
return jsonify({'success': True}), 200
if __name__ == '__main__':
app.run(port=3000)
PHP/Laravel
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
Route::post('/webhooks/stripe', function (Request $request) {
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$webhookSecret = config('services.stripe.webhook_secret');
// Check if this is a replay
$isReplay = $request->header('X-Jetty-Replay') === 'true';
if (!$isReplay) {
try {
// Verify the webhook signature
$event = Webhook::constructEvent(
$payload,
$signature,
$webhookSecret
);
Log::info('Webhook signature verified');
} catch (\UnexpectedValueException $e) {
// Invalid payload
Log::error('Invalid payload: ' . $e->getMessage());
return response('Invalid payload', 400);
} catch (SignatureVerificationException $e) {
// Invalid signature
Log::error('Invalid signature: ' . $e->getMessage());
return response('Invalid signature', 400);
}
} else {
// For replays, parse without verification
$event = json_decode($payload, true);
Log::info('Replay detected - skipping verification');
}
// Handle the event
switch ($event['type']) {
case 'payment_intent.succeeded':
$paymentIntent = $event['data']['object'];
Log::info('Payment succeeded: ' . $paymentIntent['id']);
handlePaymentSuccess($paymentIntent);
break;
case 'payment_intent.payment_failed':
$paymentIntent = $event['data']['object'];
Log::info('Payment failed: ' . $paymentIntent['id']);
handlePaymentFailure($paymentIntent);
break;
case 'customer.subscription.created':
$subscription = $event['data']['object'];
Log::info('New subscription: ' . $subscription['id']);
handleSubscriptionCreated($subscription);
break;
default:
Log::info('Unhandled event type: ' . $event['type']);
}
return response()->json(['received' => true]);
});
Common Mistakes
Parsing body as JSON before verification
// WRONG - body is parsed as JSON first
app.use(express.json());
app.post('/webhooks/stripe', (req, res) => {
// req.body is already parsed - signature will fail!
stripe.webhooks.constructEvent(req.body, signature, secret);
});
Stripe's signature is computed on the raw body. If you parse it first, verification will fail.
Use raw body for webhook route
// CORRECT - use express.raw() for webhook endpoint
app.post('/webhooks/stripe',
express.raw({type: 'application/json'}),
(req, res) => {
// req.body is raw Buffer - signature works!
stripe.webhooks.constructEvent(req.body, signature, secret);
}
);
Using API key instead of signing secret
// WRONG - webhook_secret is different from your API key
const webhookSecret = 'sk_test_...'; // This is your API key, not webhook secret!
Use the signing secret
// CORRECT - signing secret starts with whsec_
const webhookSecret = 'whsec_...'; // Webhook signing secret
Each webhook endpoint has its own signing secret in the Stripe Dashboard.
Testing Signature Verification
Test that verification works:
- Start your server with signature verification enabled
- Trigger a webhook from Stripe
- Should succeed
Test that it rejects invalid signatures:
# Send a webhook with invalid signature
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: invalid" \
-d '{"test": true}'
Should fail with "Invalid signature"
Step 9: Test, Debug, and Iterate
Congratulations! You now have a complete webhook testing workflow with Jetty.
What You've Accomplished
Stable webhook URL with reserved subdomain
Local testing without deployments
Full inspection of webhook payloads
Instant replay for rapid testing
Secure verification with signature checks
Your New Workflow
Instead of the old, slow workflow:
Write code → Deploy → Test → Check logs → Fix → Deploy → Repeat
You now have:
Write code → Replay webhook → Check logs → Fix → Replay → Done!
This is transformative for webhook development.
Debugging Tips
1. Webhook Times Out
Symptom: Stripe shows "Timed out" delivery status
Causes:
- Your handler is taking too long (>30 seconds)
- Your server crashed
- Tunnel disconnected
Solution:
- Respond with 200 immediately, process asynchronously
- Check server logs for crashes
- Verify
jetty shareis still running
// Bad - slow synchronous processing
app.post('/webhooks/stripe', async (req, res) => {
await slowDatabaseOperation(); // Takes 45 seconds!
res.json({received: true});
});
// Good - fast response, async processing
app.post('/webhooks/stripe', async (req, res) => {
const event = req.body;
// Respond immediately
res.json({received: true});
// Process asynchronously
processWebhookAsync(event).catch(console.error);
});
2. Duplicate Webhook Events
Symptom: Same webhook processed multiple times
Causes:
- Network retry from Stripe
- Your handler takes too long to respond
- No idempotency checks
Solution:
Implement idempotency using event ID:
// Store processed event IDs
const processedEvents = new Set();
app.post('/webhooks/stripe', async (req, res) => {
const event = req.body;
const eventId = event.id;
// Check if already processed
if (processedEvents.has(eventId)) {
console.log(`Event ${eventId} already processed, skipping`);
return res.json({received: true});
}
// Process the event
await processWebhook(event);
// Mark as processed
processedEvents.add(eventId);
res.json({received: true});
});
For production, store event IDs in your database:
const processed = await db.webhookEvents.findOne({ stripeEventId: eventId });
if (processed) {
return res.json({received: true});
}
await processWebhook(event);
await db.webhookEvents.create({ stripeEventId: eventId });
3. Events Arrive Out of Order
Symptom: subscription.deleted arrives before subscription.created
Cause: Webhooks are sent in parallel and network conditions vary
Solution:
Don't assume chronological order. Check the created timestamp:
if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object;
const existing = await db.subscriptions.findOne({ stripeId: subscription.id });
if (existing && existing.lastEventTimestamp > event.created) {
console.log('Received out-of-order event, ignoring');
return res.json({received: true});
}
// This event is newer, process it
await updateSubscription(subscription);
await db.subscriptions.updateOne(
{ stripeId: subscription.id },
{ lastEventTimestamp: event.created }
);
}
4. Signature Verification Fails on Replay
Symptom: Replayed webhooks fail signature verification
Cause: Stripe signatures include a timestamp and are only valid for 5 minutes
Solution:
Detect replays and skip verification:
const isReplay = req.headers['x-jetty-replay'] === 'true';
if (!isReplay) {
stripe.webhooks.constructEvent(req.body, signature, webhookSecret);
} else {
event = JSON.parse(req.body);
}
5. Missing or Incomplete Data
Symptom: Expected fields are null or missing
Cause: Stripe's API versions can change event structure
Solution:
Check your API version and the event object carefully:
console.log('API Version:', event.api_version);
console.log('Event data:', JSON.stringify(event.data.object, null, 2));
Use optional chaining for potentially missing fields:
const email = event.data.object?.receipt_email ?? 'No email';
const customer = event.data.object?.customer ?? null;
Pro Tips
1. Create Aliases for Common Tunnels
# Add to your ~/.bashrc or ~/.zshrc
alias stripe-webhooks='jetty share 3000 --subdomain=stripe-webhooks'
alias github-webhooks='jetty share 3000 --subdomain=github-webhooks'
alias shopify-webhooks='jetty share 3000 --subdomain=shopify-webhooks'
# Now just run:
stripe-webhooks
2. Use Descriptive Subdomain Names
# Good - clear purpose
jetty share 3000 --subdomain=myapp-stripe-dev
jetty share 3000 --subdomain=alice-payment-testing
jetty share 3000 --subdomain=staging-stripe-webhooks
# Bad - vague
jetty share 3000 --subdomain=test
jetty share 3000 --subdomain=webhooks
jetty share 3000 --subdomain=dev
3. Bookmark Traffic Inspector
Bookmark your tunnel's Traffic Inspector page for quick access:
https://usejetty.online/tunnels/stripe-webhooks/traffic
4. Test Edge Cases with Replay
Use replay to test scenarios that are hard to trigger:
- Edit amount to
999999to test large payments - Remove optional fields to test missing data
- Change currency to test international payments
- Modify status to test different states
5. Set Up Logging Middleware
Add logging middleware to see all webhook traffic:
app.post('/webhooks/stripe', (req, res, next) => {
console.log('--- Webhook Received ---');
console.log('Time:', new Date().toISOString());
console.log('Headers:', req.headers);
console.log('Body:', req.body.toString());
next();
}, handleWebhook);
Next Steps
Now that you've mastered Stripe webhooks with Jetty, try:
-
Test Other Services
-
Set Up Custom Domains
- Use
webhooks.yourcompany.cominstead oftunnels.usejetty.online - Custom Domains Tutorial
- Use
-
Explore Advanced Features
- Traffic filtering and search
- Request/response modification
- Team collaboration
- Traffic Inspector Guide
-
Production Best Practices
- Implement robust error handling
- Set up monitoring and alerting
- Add comprehensive logging
- Webhook Best Practices
Common Troubleshooting
Webhook Not Received
Check these in order:
- Is
jetty sharestill running? (Check terminal) - Is your local server running? (
curl http://localhost:3000/webhooks/stripe) - Is the Stripe webhook configured with the correct URL?
- Did you send a test webhook from Stripe?
- Check Stripe's webhook delivery logs for errors
404 Not Found
Symptom: Stripe shows "404 Not Found" delivery status
Cause: Webhook path doesn't match your route
Solution:
# Your route is /webhooks/stripe
# But you configured Stripe with /webhook/stripe (singular)
# Fix: Update Stripe webhook URL to:
https://your-subdomain.tunnels.usejetty.online/webhooks/stripe
500 Internal Server Error
Symptom: Stripe shows "500 Internal Server Error"
Cause: Your webhook handler is crashing
Solution:
- Check your application logs for the error
- Add try-catch to prevent crashes:
app.post('/webhooks/stripe', async (req, res) => {
try {
const event = req.body;
await processWebhook(event);
res.json({received: true});
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({error: error.message});
}
});
Signature Verification Fails
Common causes:
- Wrong signing secret - Make sure you copied the secret from the correct webhook endpoint
- Body already parsed - Use
express.raw()instead ofexpress.json() - Replayed webhook - Check for
X-Jetty-Replayheader and skip verification - Old webhook - Signatures expire after 5 minutes
Can't See Webhook in Traffic Inspector
Check:
- You're looking at the correct tunnel
- The Traffic tab is selected
- Your tunnel received the request (check CLI logs)
- Refresh the page (traffic updates every few seconds)
Related Resources
Documentation
- Webhook Testing User Guide - Complete webhook testing reference
- Traffic Inspector Guide - Advanced inspection features
- Custom Domains - Use your own domain for webhooks
- CLI Usage Guide - All Jetty CLI commands
Stripe Resources
- Stripe Webhooks Guide - Official Stripe webhook documentation
- Webhook Events Reference - All available webhook events
- Webhook Best Practices - Stripe's recommended practices
- Signature Verification - How Stripe signs webhooks
- Stripe CLI - Command-line tool for Stripe
Other Webhook Providers
Feedback
We'd love to hear about your experience with this tutorial!
You now have the skills to test webhooks from any provider, debug issues instantly, and iterate faster than ever before.
Send feedback
Found an issue or have a suggestion? Let us know.