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:
- Write webhook handler code
- Deploy to staging server
- Trigger webhook from service
- Check logs on remote server
- Make changes and redeploy
- Repeat
With Jetty:
- Write webhook handler code
- Run
jetty share 8000 --subdomain=my-webhooks - Trigger webhook from service
- See full payload in Traffic Inspector
- Replay locally to test changes
- 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:
- Navigate to Tunnels in the sidebar
- Click on your
my-webhookstunnel - Open the Traffic tab
- 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:
- Open the Jetty Dashboard
- Go to Tunnels in the sidebar
- Click on your webhook tunnel (e.g.,
github-webhooks) - 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:
- Trigger a webhook event
- Check the Traffic Inspector to confirm the webhook arrived
- Verify the payload structure matches what you expect
- Check that signature/auth headers are present
- Look at your server's response to see if it handled it correctly
- 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
- Open the Traffic Inspector for your tunnel
- Click on the webhook request you want to replay
- Click the Replay button
- 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:
-
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 -
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 }); -
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 -
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:
-
Header redaction: Check your traffic inspection redaction settings. "Strict" tier redacts more headers.
- Go to Settings → API Tokens → Select token → Check redaction tier
- Use "Standard" tier for webhook development
-
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'] -
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:
-
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-01or latest in webhook creation
-
Test vs. production modes: Test mode payloads sometimes differ slightly
- Stripe test mode uses test IDs (
pi_test_...) - Shopify development stores have different data
- Stripe test mode uses test IDs (
-
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:
-
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); }); }); -
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" -
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 -
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:
-
Tunnel disconnected: Ensure
jetty shareis still running# Check running tunnels jetty list # Restart if needed jetty share 8000 --subdomain=my-webhooks -
Duplicate tunnel processes: Multiple
jetty shareprocesses 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 jettythen restart
-
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:
-
Start your local server:
npm start # or equivalent for your stack -
Start Jetty with a reserved subdomain:
jetty share 8000 --subdomain=my-webhooks -
Configure webhook in provider (one-time setup):
- Use
https://my-webhooks.tunnels.usejetty.online/webhooks/provider - Copy webhook secret to your
.envfile
- Use
-
Trigger a webhook event:
- Push to GitHub, create test payment in Stripe, etc.
-
Inspect in dashboard:
- Open Traffic Inspector
- Verify payload structure
- Check headers and signatures
-
Iterate locally:
- Make code changes
- Replay the webhook:
jetty replay my-webhooks --latest --unsafe - No need to trigger a new event each time!
-
Test edge cases:
- Replay with modified payloads (copy from inspector, modify, send via curl)
- Test error handling
- Test signature validation
-
Deploy with confidence:
- Once working locally, deploy to staging/production
- Webhook URL doesn't change if using custom domain
Pro tips:
- Keep
jetty sharerunning in a dedicated terminal tab - Use
--subdomainconsistently 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:
- Respond with
200 OKimmediately - Process webhook asynchronously
- 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:
- Reserve a subdomain for stable webhook URLs
- Configure your webhook provider once
- Test locally with real webhook payloads
- Inspect traffic in the dashboard to debug
- Replay requests to iterate quickly
- Verify signatures to ensure security
- 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
- Navigate to Dashboard > Webhooks in the Jetty UI.
- Click New endpoint.
- Enter a label (e.g., "Stripe Dev") and optionally an HMAC secret.
- 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
- Click a capture to view its details.
- Enter the target URL (e.g.,
http://localhost:8000) in the replay field. - Click Replay to forward the request with the original method, headers, and body.
- 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
- Create a GitHub personal access token with
reposcope at github.com/settings/tokens. - In the Jetty dashboard, go to Settings > CI/CD Integrations.
- Enter your GitHub token and repository (e.g.,
myorg/myrepo). - 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
- Create a Slack Incoming Webhook at api.slack.com/messaging/webhooks.
- In the Jetty dashboard, go to Settings > CI/CD Integrations.
- Paste the webhook URL (starts with
https://hooks.slack.com/services/...). - Click Save Slack.
Slack receives Block Kit messages for tunnel_started, tunnel_stopped, and tunnel_expired events.
Setting Up Discord Notifications
- In your Discord server, go to Server Settings > Integrations > Webhooks and create a new webhook.
- Copy the webhook URL.
- In the Jetty dashboard, go to Settings > CI/CD Integrations.
- Paste the webhook URL (starts with
https://discord.com/api/webhooks/...). - 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.
Related documentation
- Webhook provider integrations -- provider-specific guides for GitHub, Stripe, Shopify, Twilio, and more
- Tunnel settings -- edge-level signature verification, basic auth, rate limits
- Reserved subdomains -- stable tunnel URLs for webhook endpoints
- Traffic inspection -- view and replay captured requests
- API tokens -- CLI and API authentication
- Custom domains -- use your own domain for production webhooks
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.