Documentation for Jetty

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:

  1. Write webhook handler code
  2. Deploy to staging server
  3. Configure webhook in provider
  4. Trigger test event
  5. Check remote logs
  6. Find bug, fix code
  7. Deploy again... repeat endlessly

With Jetty, your workflow becomes:

  1. Write webhook handler code
  2. Run jetty share with a reserved subdomain
  3. Configure webhook once (URL never changes)
  4. Trigger test event
  5. See full payload in Traffic Inspector
  6. Fix bug and replay webhook instantly
  7. 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-webhooks vs ae3f91c
  • 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:

  1. Jetty checks if stripe-webhooks is available for your organization
  2. If available, it's automatically reserved for you
  3. You get https://stripe-webhooks.tunnels.usejetty.online
  4. This URL is now yours to use anytime

Choosing a Good Subdomain Name

Good names:

  • stripe-webhooks - Clear purpose
  • myapp-stripe-dev - Includes app context
  • alice-payment-hooks - Personal dev environment
  • staging-stripe - Environment-specific

Avoid:

  • test - Too generic, likely taken
  • webhooks - 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?

  1. Your subdomain was reserved (or confirmed if already reserved)
  2. A secure HTTPS tunnel was created
  3. All traffic to https://stripe-webhooks.tunnels.usejetty.online now forwards to http://localhost:3000
  4. 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

  1. Log in to your Stripe Dashboard
  2. Click Developers in the top navigation
  3. Select Webhooks from the left sidebar
  4. 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 successfully
  • payment_intent.payment_failed - Payment failed
  • charge.succeeded - Charge created successfully
  • charge.refunded - Refund processed
  • customer.created - New customer created
  • customer.subscription.created - Subscription started
  • customer.subscription.deleted - Subscription cancelled
  • invoice.payment_succeeded - Invoice paid
  • invoice.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:

  1. In Developers → Webhooks, click on your webhook endpoint
  2. Click the Send test webhook button
  3. Select an event type from the dropdown (e.g., payment_intent.succeeded)
  4. 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:

  1. Use Stripe's test card numbers
  2. Create a payment through your app (or Stripe dashboard)
  3. 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:

  1. Go to Stripe Dashboard → Payments
  2. Click NewPayment
  3. Enter any amount (e.g., $10.00)
  4. Use test card: 4242 4242 4242 4242
  5. 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:

  1. Go to Developers → Webhooks
  2. Click on your endpoint
  3. Scroll down to Events
  4. 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

  1. Open the Jetty Dashboard
  2. Click Tunnels in the left sidebar
  3. Click on your tunnel (e.g., stripe-webhooks)
  4. Open the Traffic tab
  5. 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/json for 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: false for test mode, true for production

Why This is Powerful

Without Jetty, to see this data you'd need to:

  1. Add logging code to your webhook handler
  2. Deploy to a server
  3. Trigger a webhook
  4. SSH into the server
  5. Dig through log files
  6. Find the right request
  7. Parse the logged data

With Jetty's Traffic Inspector:

  1. Trigger webhook
  2. Click on it in the dashboard
  3. See everything immediately

This changes everything for webhook debugging!

Copy Data for Testing

You can copy the entire request body to use in tests:

  1. Click the Copy button next to the request body
  2. Paste into your test fixtures or mock data
  3. 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:

  1. Find bug in webhook handler
  2. Fix the code
  3. Go back to Stripe dashboard
  4. Trigger a new test webhook
  5. Hope it works
  6. Repeat if it doesn't

With Jetty replay:

  1. Find bug in webhook handler
  2. Fix the code
  3. Click "Replay" button
  4. Done!

No context switching, no waiting, instant feedback.

How to Replay

  1. In the Traffic Inspector, select the webhook request you want to replay
  2. Click the Replay button (↻ icon) in the top right
  3. (Optional) Edit the request body or headers to test edge cases
  4. Click Send Replay
  5. 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

  1. Stripe creates a signature using your webhook signing secret
  2. The signature is sent in the Stripe-Signature header
  3. Your code re-computes the signature using the same secret
  4. If they match, the webhook is authentic
  5. If they don't match, reject it

Get Your Signing Secret

  1. In Stripe Dashboard, go to Developers → Webhooks
  2. Click on your webhook endpoint
  3. Under "Signing secret", click Reveal
  4. Copy the secret (starts with whsec_...)
  5. Add it to your .env file:
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:

  1. Start your server with signature verification enabled
  2. Trigger a webhook from Stripe
  3. 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 share is 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 999999 to 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:

  1. Test Other Services

  2. Set Up Custom Domains

  3. Explore Advanced Features

  4. 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:

  1. Is jetty share still running? (Check terminal)
  2. Is your local server running? (curl http://localhost:3000/webhooks/stripe)
  3. Is the Stripe webhook configured with the correct URL?
  4. Did you send a test webhook from Stripe?
  5. 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:

  1. Check your application logs for the error
  2. 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:

  1. Wrong signing secret - Make sure you copied the secret from the correct webhook endpoint
  2. Body already parsed - Use express.raw() instead of express.json()
  3. Replayed webhook - Check for X-Jetty-Replay header and skip verification
  4. Old webhook - Signatures expire after 5 minutes

Can't See Webhook in Traffic Inspector

Check:

  1. You're looking at the correct tunnel
  2. The Traffic tab is selected
  3. Your tunnel received the request (check CLI logs)
  4. Refresh the page (traffic updates every few seconds)

Documentation

Stripe Resources

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.