Documentation for Jetty

Webhook Testing with Jetty

Overview

Jetty makes webhook development fast and painless by giving you a secure tunnel to your local server. Instead of deploying to a staging server every time you want to test a webhook from GitHub, Stripe, Twilio, or Shopify, you can receive real webhook payloads directly on your local machine, inspect them in detail, and replay them as needed.

This guide covers:

  • Setting up stable webhook URLs with reserved subdomains
  • Inspecting webhook payloads for debugging
  • Using replay to test your webhook handlers
  • Verifying webhook signatures
  • Common issues and solutions

Why Use Jetty for Webhooks

Traditional webhook development is slow:

  1. Write webhook handler code
  2. Deploy to staging server
  3. Trigger webhook from service
  4. Check logs on remote server
  5. Make changes and redeploy
  6. Repeat

With Jetty:

  1. Write webhook handler code
  2. Run jetty share 8000 --subdomain=my-webhooks
  3. Trigger webhook from service
  4. See full payload in Traffic Inspector
  5. Replay locally to test changes
  6. Fix and replay again

Benefits:

  • No Deployment Required: Test webhook handlers without leaving your local environment
  • Real Payloads: Receive actual webhook data from production services, not mocked data
  • Full Inspection: See complete request headers, body, and metadata in the dashboard
  • Instant Replay: Re-send webhooks to test edge cases and error handling
  • Stable URLs: Use reserved subdomains so you don't have to reconfigure webhooks constantly
  • Team Friendly: Multiple developers can test webhooks simultaneously with different subdomains

Quick Start

1. Reserve a Subdomain

Reserve a memorable subdomain for your webhook testing:

jetty share 8000 --subdomain=my-webhooks

The first time you use a new subdomain name, Jetty automatically reserves it for your organization. You'll get:

 Subdomain reserved: my-webhooks
 Tunnel created: https://my-webhooks.tunnels.usejetty.online
Forwarding traffic to http://localhost:8000

Now you can use https://my-webhooks.tunnels.usejetty.online as your webhook URL.

Pro tip: Use descriptive names like github-webhooks, stripe-dev, or shopify-staging to organize multiple webhook endpoints.

2. Configure Your Service

Copy your tunnel URL and paste it into your webhook provider's settings:

  • GitHub: Repository Settings → Webhooks → Add webhook
  • Stripe: Developers → Webhooks → Add endpoint
  • Twilio: Phone Numbers → Configure → Webhook URL
  • Shopify: Settings → Notifications → Webhooks

3. Test and Inspect

Trigger a webhook event (push to GitHub, make a test payment in Stripe, send an SMS to your Twilio number, etc.).

Your local server receives the request, and you can inspect the full payload in the Jetty Dashboard:

  1. Navigate to Tunnels in the sidebar
  2. Click on your my-webhooks tunnel
  3. Open the Traffic tab
  4. Select the webhook request to see:
    • Full request body (JSON payload)
    • All headers (including signature headers)
    • Request method and path
    • Timestamp and response status

Debugging Webhooks

Using Traffic Inspector

The Traffic Inspector is your best tool for debugging webhook issues. It captures full request details for every webhook that hits your tunnel.

To access the inspector:

  1. Open the Jetty Dashboard
  2. Go to Tunnels in the sidebar
  3. Click on your webhook tunnel (e.g., github-webhooks)
  4. Open the Traffic tab

What you can see:

  • Request body: Full JSON payload from the webhook provider
  • Headers: All headers including signatures, event types, and IDs
  • Timestamp: Exactly when the webhook arrived
  • Response status: What your server returned (200, 400, 500, etc.)
  • Response time: How long your handler took

Debugging workflow:

  1. Trigger a webhook event
  2. Check the Traffic Inspector to confirm the webhook arrived
  3. Verify the payload structure matches what you expect
  4. Check that signature/auth headers are present
  5. Look at your server's response to see if it handled it correctly
  6. If your handler failed, replay the request after fixing your code

Pro tips:

  • Use the path filter to show only webhook requests: /webhooks/
  • Look for X-* headers specific to each provider (e.g., X-GitHub-Event, X-Stripe-Signature)
  • Compare successful vs. failed requests to spot differences
  • Check response times to identify slow webhook handlers that might timeout

Using Replay

Replay lets you re-send captured webhook requests to your local server. This is incredibly useful for:

  • Testing fixes without triggering new webhook events
  • Testing edge cases and error scenarios
  • Developing webhook handlers with real data
  • Training team members with production-like payloads

From the Dashboard

  1. Open the Traffic Inspector for your tunnel
  2. Click on the webhook request you want to replay
  3. Click the Replay button
  4. The request is re-sent to your current tunnel with the same headers and body

This works even if your tunnel is disconnected and reconnected, as long as you're using a reserved subdomain.

From the CLI

Replay webhook requests directly from your terminal:

# Start your tunnel
jetty share 8000 --subdomain=stripe-webhooks

# In another terminal, replay the most recent request
jetty replay stripe-webhooks --latest

# Replay a specific request by ID (get ID from dashboard or previous replay output)
jetty replay stripe-webhooks --request-id=req_abc123xyz

# Replay the 5 most recent requests
jetty replay stripe-webhooks --latest --count=5

Replay safety:

By default, Jetty only replays GET and HEAD requests to prevent accidental mutations. To replay webhook POST requests, use the --unsafe flag:

jetty replay stripe-webhooks --latest --unsafe

Use cases:

# Test Stripe payment handler
jetty replay stripe-webhooks --latest --unsafe

# Test GitHub push event handler  
jetty replay github-webhooks --latest --unsafe

# Replay last 10 Shopify order webhooks
jetty replay shopify-webhooks --latest --count=10 --unsafe

Common Issues

Signature Verification Failures

Symptom: Webhooks arrive but your handler returns 401 Unauthorized or 403 Forbidden.

Causes and solutions:

  1. Wrong secret: Verify you're using the correct webhook secret from the provider's dashboard

    # Check environment variables
    echo $STRIPE_WEBHOOK_SECRET
    echo $GITHUB_WEBHOOK_SECRET
    
  2. Body parsing: For signature verification to work, you need the raw request body, not parsed JSON

    // Wrong - body is parsed
    app.use(express.json());
    app.post('/webhooks/stripe', (req, res) => {
      // req.body is an object, but you need the raw string for HMAC
    });
    
    // Right - use raw body for webhook routes
    app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
      // req.body is a Buffer with the raw bytes
    });
    
  3. URL mismatch (Twilio): Twilio validation includes the full URL. Make sure it matches:

    // URL must exactly match what Twilio used
    const url = 'https://twilio-webhooks.tunnels.usejetty.online/webhooks/twilio/sms';
    // Not: http://localhost:8000/webhooks/twilio/sms
    
  4. Replay with old signatures: Replayed requests include the original signature, which may have expired. For testing, temporarily disable signature validation or implement a "test mode" flag.

Missing Headers

Symptom: Expected headers like X-GitHub-Event or Stripe-Signature don't appear in your handler.

Causes and solutions:

  1. Header redaction: Check your traffic inspection redaction settings. "Strict" tier redacts more headers.

    • Go to SettingsAPI Tokens → Select token → Check redaction tier
    • Use "Standard" tier for webhook development
  2. Framework normalization: Some frameworks normalize header names. Check docs:

    // Express - lowercase with dashes
    req.headers['x-github-event']
    
    // Laravel - uppercase with underscores, prefixed with HTTP_
    $request->header('X-GitHub-Event')
    $_SERVER['HTTP_X_GITHUB_EVENT']
    
  3. Proxy stripping: Ensure no proxy between Jetty and your app is stripping headers

Payload Format Mismatches

Symptom: Webhook arrives but payload structure doesn't match documentation.

Causes and solutions:

  1. API version: Providers change payload formats across API versions

    • GitHub: Check repository webhook settings for API version
    • Stripe: Use a consistent API version in dashboard and code
    • Shopify: Specify 2024-01 or latest in webhook creation
  2. Test vs. production modes: Test mode payloads sometimes differ slightly

    • Stripe test mode uses test IDs (pi_test_...)
    • Shopify development stores have different data
  3. Documentation lag: Provider docs may be outdated. Use the Traffic Inspector as the source of truth for actual payload structure.

Timeout Issues

Symptom: Webhook provider shows delivery failed with timeout error.

Causes and solutions:

  1. Slow processing: Webhook handlers should respond quickly (< 5 seconds). Most providers timeout at 10-30 seconds.

    // Bad - blocks response
    app.post('/webhooks/stripe', async (req, res) => {
      await processPayment(req.body); // Could take 20 seconds
      res.send('OK');
    });
    
    // Good - respond immediately, process async
    app.post('/webhooks/stripe', async (req, res) => {
      res.send('OK'); // Respond first
    
      // Process in background
      processPayment(req.body).catch(err => {
        console.error('Error processing webhook:', err);
      });
    });
    
  2. Local server not running: Ensure your application is running before triggering webhooks

    # Check if server is listening
    curl http://localhost:8000/health
    
    # Or check Jetty connection
    jetty share 8000 --subdomain=my-webhooks
    # Should show "Forwarding traffic to http://localhost:8000"
    
  3. Port mismatch: Verify Jetty is forwarding to the correct local port

    # If your app runs on 3000, not 8000
    jetty share 3000 --subdomain=my-webhooks
    
  4. Application crash: Check your application logs for errors that might cause it to hang or crash

"Tunnel Unavailable" Errors

Symptom: Webhooks fail with "tunnel unavailable" or similar error from the provider.

Causes and solutions:

  1. Tunnel disconnected: Ensure jetty share is still running

    # Check running tunnels
    jetty list
    
    # Restart if needed
    jetty share 8000 --subdomain=my-webhooks
    
  2. Duplicate tunnel processes: Multiple jetty share processes for the same tunnel cause conflicts

    • Since CLI v0.1.19, a lockfile prevents duplicates
    • Check for stale processes: ps aux | grep jetty
    • Kill duplicates: killall jetty then restart
  3. Network issues: Temporary network interruption between your machine and Jetty edge servers

    • Jetty automatically reconnects
    • Wait a moment and retry the webhook

Development Workflow

Local Development Loop

The ideal workflow for webhook development with Jetty:

  1. Start your local server:

    npm start  # or equivalent for your stack
    
  2. Start Jetty with a reserved subdomain:

    jetty share 8000 --subdomain=my-webhooks
    
  3. Configure webhook in provider (one-time setup):

    • Use https://my-webhooks.tunnels.usejetty.online/webhooks/provider
    • Copy webhook secret to your .env file
  4. Trigger a webhook event:

    • Push to GitHub, create test payment in Stripe, etc.
  5. Inspect in dashboard:

    • Open Traffic Inspector
    • Verify payload structure
    • Check headers and signatures
  6. Iterate locally:

    • Make code changes
    • Replay the webhook: jetty replay my-webhooks --latest --unsafe
    • No need to trigger a new event each time!
  7. Test edge cases:

    • Replay with modified payloads (copy from inspector, modify, send via curl)
    • Test error handling
    • Test signature validation
  8. Deploy with confidence:

    • Once working locally, deploy to staging/production
    • Webhook URL doesn't change if using custom domain

Pro tips:

  • Keep jetty share running in a dedicated terminal tab
  • Use --subdomain consistently so webhook configs don't need updating
  • Save interesting webhook payloads as test fixtures for unit tests
  • Use environment variables for webhook secrets, never hardcode

Team Workflows

Multiple developers can test webhooks simultaneously using different reserved subdomains:

Example: Team developing Stripe integration

Developer A:

jetty share 8000 --subdomain=stripe-dev-alice
# Configures Stripe endpoint: https://stripe-dev-alice.tunnels.usejetty.online

Developer B:

jetty share 8000 --subdomain=stripe-dev-bob
# Configures Stripe endpoint: https://stripe-dev-bob.tunnels.usejetty.online

Each developer gets their own webhook endpoint and can test independently without conflicts.

Shared development webhook:

For shared testing (QA, demos), use a shared reserved subdomain:

# Any team member can connect
jetty share 8000 --subdomain=stripe-qa

Configure one Stripe webhook endpoint at https://stripe-qa.tunnels.usejetty.online. Whoever is connected receives the webhooks.

CI/CD integration:

Test webhooks in CI pipelines using reserved subdomains:

# .github/workflows/test-webhooks.yml
name: Webhook Integration Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Install dependencies
        run: npm install
      
      - name: Start application
        run: npm start &
        env:
          PORT: 8000
      
      - name: Create Jetty tunnel
        run: |
          curl -fsSL https://usejetty.online/install/jetty.sh | bash
          jetty share 8000 --subdomain=ci-webhooks-${{ github.run_id }} &
        env:
          JETTY_API_TOKEN: ${{ secrets.JETTY_API_TOKEN }}
      
      - name: Run webhook tests
        run: npm run test:webhooks
        env:
          WEBHOOK_URL: https://ci-webhooks-${{ github.run_id }}.tunnels.usejetty.online

Best Practices

Use Reserved Subdomains

Do this:

jetty share 8000 --subdomain=github-webhooks

Configure webhook once with https://github-webhooks.tunnels.usejetty.online.

Not this:

jetty share 8000

Random subdomain changes every time, requiring webhook reconfiguration.

One Webhook Endpoint per Service

Separate webhook endpoints by provider:

/webhooks/github     → Handle GitHub events
/webhooks/stripe     → Handle Stripe events
/webhooks/twilio/sms → Handle Twilio SMS
/webhooks/shopify    → Handle Shopify events

This makes signature verification easier (each provider has different secrets) and improves debugging.

Log Webhook Payloads

Always log incoming webhooks for debugging:

app.post('/webhooks/stripe', (req, res) => {
  console.log('Stripe webhook received:', {
    event: req.headers['stripe-signature'],
    timestamp: new Date().toISOString(),
    body: req.body
  });
  
  // Handle webhook...
});

In production, use structured logging:

logger.info('stripe_webhook_received', {
  event_type: event.type,
  event_id: event.id,
  created: event.created,
  livemode: event.livemode
});

Verify Signatures in Production

Always verify webhook signatures in production to prevent:

  • Malicious requests from attackers
  • Accidental misconfigured webhooks
  • Replay attacks
// Development: Log but don't block
if (!verifySignature(req, secret)) {
  console.warn('Invalid signature - would block in production');
  // Continue processing for easier development
}

// Production: Strictly validate
if (!verifySignature(req, secret)) {
  return res.status(401).send('Invalid signature');
}

Use environment variable to control:

const STRICT_VALIDATION = process.env.NODE_ENV === 'production';

if (!verifySignature(req, secret)) {
  if (STRICT_VALIDATION) {
    return res.status(401).send('Invalid signature');
  } else {
    console.warn('Invalid signature - allowing in development');
  }
}

Test Failure Scenarios

Don't just test the happy path. Test how your handlers respond to:

  • Invalid signatures
  • Malformed JSON
  • Missing required fields
  • Unexpected event types
  • Duplicate deliveries
  • Out-of-order deliveries
app.post('/webhooks/stripe', (req, res) => {
  try {
    const event = stripe.webhooks.constructEvent(req.body, sig, secret);
    
    // Test missing data
    if (!event.type) {
      throw new Error('Missing event type');
    }
    
    // Handle webhook...
    
    res.json({received: true});
  } catch (err) {
    // Log error but still return 200 to prevent retries
    console.error('Webhook processing error:', err);
    res.status(400).send(`Webhook Error: ${err.message}`);
  }
});

Respond Quickly

Webhook providers typically timeout after 10-30 seconds. Always:

  1. Respond with 200 OK immediately
  2. Process webhook asynchronously
  3. Use background jobs for slow operations
app.post('/webhooks/stripe', async (req, res) => {
  // Verify signature first
  const event = stripe.webhooks.constructEvent(req.body, sig, secret);
  
  // Respond immediately
  res.json({received: true});
  
  // Process in background
  setImmediate(async () => {
    try {
      await handleStripeEvent(event);
    } catch (err) {
      console.error('Error processing webhook:', err);
      // Could push to dead letter queue, retry with backoff, alert, etc.
    }
  });
});

Or use a job queue:

app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, sig, secret);
  
  // Queue for processing
  await queue.add('stripe-webhook', {
    eventId: event.id,
    eventType: event.type,
    data: event.data
  });
  
  res.json({received: true});
});

Use Idempotency Keys

Handle duplicate webhook deliveries gracefully:

const processedEvents = new Set();

app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, sig, secret);
  
  // Check if already processed
  if (processedEvents.has(event.id)) {
    console.log('Duplicate event, ignoring:', event.id);
    return res.json({received: true});
  }
  
  // Mark as processed
  processedEvents.add(event.id);
  
  // Process webhook...
  await handleEvent(event);
  
  res.json({received: true});
});

In production, use a database or cache:

app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, sig, secret);
  
  // Check database
  const existing = await db.webhookEvents.findOne({eventId: event.id});
  if (existing) {
    return res.json({received: true});
  }
  
  // Store and process
  await db.webhookEvents.create({
    eventId: event.id,
    processed: false,
    createdAt: new Date()
  });
  
  await handleEvent(event);
  
  await db.webhookEvents.updateOne(
    {eventId: event.id},
    {processed: true, processedAt: new Date()}
  );
  
  res.json({received: true});
});

Summary

Jetty transforms webhook development from a slow, deploy-heavy process into a fast, local-first workflow:

  1. Reserve a subdomain for stable webhook URLs
  2. Configure your webhook provider once
  3. Test locally with real webhook payloads
  4. Inspect traffic in the dashboard to debug
  5. Replay requests to iterate quickly
  6. Verify signatures to ensure security
  7. Handle failures gracefully with idempotency

With Jetty, you can develop and test webhooks from GitHub, Stripe, Twilio, Shopify, and any other provider without deploying to a remote server. The combination of stable URLs, traffic inspection, and request replay makes webhook development fast, reliable, and enjoyable.

Webhook Testing Hub (Catch-All Endpoints)

In addition to testing webhooks through tunnels, Jetty provides a Webhook Testing Hub that lets you catch and inspect webhooks without a running local server.

How Webhook Endpoints Work

A webhook endpoint is a persistent, public URL that captures every request sent to it. No tunnel or local server is needed -- Jetty stores the request data for you to inspect and replay later.

Each endpoint has:

  • A unique public URL like https://your-jetty-host.com/webhooks/stripe-dev-ab12cd34
  • An optional HMAC secret for signature verification
  • Up to 200 stored captures per endpoint

Creating a Webhook Endpoint

  1. Navigate to Dashboard > Webhooks in the Jetty UI.
  2. Click New endpoint.
  3. Enter a label (e.g., "Stripe Dev") and optionally an HMAC secret.
  4. Copy the generated URL and paste it into your webhook provider's settings.

Configuring Providers

Point Stripe, GitHub, or Shopify at the endpoint URL:

  • Stripe: Developers > Webhooks > Add endpoint > Paste the URL
  • GitHub: Repository Settings > Webhooks > Payload URL
  • Shopify: Settings > Notifications > Webhooks > URL

Searching and Filtering

Use the search bar in the captures list to filter by HTTP method, path, source IP, or body content.

Inspecting Captures

Click any capture to view:

  • Full request headers in a table
  • Parsed body -- JSON is pretty-printed, form-urlencoded data is shown as key-value pairs, everything else is shown raw
  • Method, path, query string, source IP, and timestamp
  • Replay status if it has been replayed

Replaying to Your Local Server

  1. Click a capture to view its details.
  2. Enter the target URL (e.g., http://localhost:8000) in the replay field.
  3. Click Replay to forward the request with the original method, headers, and body.
  4. Use Copy curl to get a curl command you can run from the terminal.

API Usage for CI/CD

All webhook endpoint operations are available via the REST API:

GET    /api/webhook-endpoints                                  # List endpoints
POST   /api/webhook-endpoints                                  # Create endpoint
DELETE /api/webhook-endpoints/{id}                              # Delete endpoint
GET    /api/webhook-endpoints/{id}/captures?search=stripe       # List captures
POST   /api/webhook-endpoints/{id}/captures/{cid}/replay        # Replay capture
DELETE /api/webhook-endpoints/{id}/captures                     # Clear captures

All API endpoints require a Bearer token via the Authorization header.

Limits

  • Each endpoint retains up to 200 captures. Oldest captures are automatically pruned.
  • Request bodies are stored up to 1 MB.
  • Replay requests have a 15-second timeout.

CI/CD Integration

Jetty can automatically post tunnel URLs to GitHub pull requests and send notifications to Slack or Discord when tunnels start, stop, or expire. This is useful for preview environments, QA workflows, and team visibility.

Setting Up GitHub PR Comments

  1. Create a GitHub personal access token with repo scope at github.com/settings/tokens.
  2. In the Jetty dashboard, go to Settings > CI/CD Integrations.
  3. Enter your GitHub token and repository (e.g., myorg/myrepo).
  4. Click Save GitHub.

When a tunnel starts in a CI environment where GITHUB_PR_NUMBER or CI_MERGE_REQUEST_IID is set, Jetty posts a comment to the PR with the tunnel URL, local target, and expiry.

Setting Up Slack Notifications

  1. Create a Slack Incoming Webhook at api.slack.com/messaging/webhooks.
  2. In the Jetty dashboard, go to Settings > CI/CD Integrations.
  3. Paste the webhook URL (starts with https://hooks.slack.com/services/...).
  4. Click Save Slack.

Slack receives Block Kit messages for tunnel_started, tunnel_stopped, and tunnel_expired events.

Setting Up Discord Notifications

  1. In your Discord server, go to Server Settings > Integrations > Webhooks and create a new webhook.
  2. Copy the webhook URL.
  3. In the Jetty dashboard, go to Settings > CI/CD Integrations.
  4. Paste the webhook URL (starts with https://discord.com/api/webhooks/...).
  5. Click Save Discord.

Discord receives embed messages color-coded by event type (green for started, red for stopped, yellow for expired).

Environment Variables for CI

Set these in your CI environment so the Jetty CLI auto-detects the PR number:

Variable CI Provider Description
GITHUB_PR_NUMBER GitHub Actions The pull request number
CI_MERGE_REQUEST_IID GitLab CI The merge request IID

Example GitHub Actions Workflow

name: Preview Environment

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start application
        run: npm start &
        env:
          PORT: 3000

      - name: Install Jetty CLI
        run: curl -fsSL https://usejetty.online/install/jetty.sh | bash

      - name: Create tunnel
        run: jetty share 3000 --subdomain=preview-pr-${{ github.event.pull_request.number }}
        env:
          JETTY_API_TOKEN: ${{ secrets.JETTY_API_TOKEN }}
          GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}

The tunnel URL is automatically posted as a comment on the PR. Slack and Discord integrations (if configured) also fire.

Edge-level signature verification

Jetty can verify webhook signatures at the edge before requests reach your local app. Configure this in Bridge under Tunnel Settings > Webhook Signature Verification. Supported providers include Stripe, GitHub, Shopify, and custom HMAC-SHA256. See Tunnel settings for setup details.

For provider-specific integration guides (GitHub, Stripe, Shopify, Twilio, and more), see Webhook provider integrations.

Send feedback

Found an issue or have a suggestion? Let us know.