Webhook Provider Integrations
Provider-specific guides for configuring webhooks with Jetty. See Webhook testing for the general workflow.
GitHub Webhooks
GitHub sends webhooks when events occur in your repositories: pushes, pull requests, releases, issues, and more.
Setting Up GitHub Webhooks
- Start your local server and Jetty tunnel:
# Start your local API or application
npm start # or php artisan serve, rails server, etc.
# In another terminal, start Jetty
jetty share 8000 --subdomain=github-webhooks
- Go to your GitHub repository
- Click Settings -> Webhooks -> Add webhook
- Configure:
- Payload URL:
https://github-webhooks.tunnels.usejetty.online/webhooks/github - Content type:
application/json - Secret: (optional but recommended) Generate a random secret string
- Events: Choose events (e.g., "Just the push event" or "Send me everything")
- Payload URL:
- Click Add webhook
Testing Push Events
Make a commit and push to your repository:
git commit --allow-empty -m "Test webhook"
git push origin main
GitHub immediately sends a webhook to your local server. Check the Traffic Inspector to see:
- Headers:
X-GitHub-Event,X-GitHub-Delivery,X-Hub-Signature-256 - Payload: Full push event data including commits, author info, and repository metadata
Common GitHub Headers
GitHub includes several useful headers:
X-GitHub-Event: Event type (push,pull_request,release, etc.)X-GitHub-Delivery: Unique ID for this webhook deliveryX-Hub-Signature-256: HMAC signature for verifying the webhook is from GitHubUser-Agent:GitHub-Hookshot/{version}
Verifying GitHub Signatures
To verify webhooks are genuinely from GitHub, validate the signature:
Node.js/Express Example:
const crypto = require('crypto');
function verifyGitHubSignature(req, secret) {
const signature = req.headers['x-hub-signature-256'];
if (!signature) {
return false;
}
const hmac = crypto.createHmac('sha256', secret);
const digest = 'sha256=' + hmac.update(req.body).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
);
}
// In your route handler
app.post('/webhooks/github', express.raw({type: 'application/json'}), (req, res) => {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!verifyGitHubSignature(req, secret)) {
return res.status(401).send('Invalid signature');
}
const event = req.headers['x-github-event'];
const payload = JSON.parse(req.body.toString());
// Handle the webhook
console.log(`Received ${event} event:`, payload);
res.status(200).send('OK');
});
PHP/Laravel Example:
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function github(Request $request)
{
$secret = config('services.github.webhook_secret');
$signature = $request->header('X-Hub-Signature-256');
if (!$signature) {
return response('Forbidden', 403);
}
$payload = $request->getContent();
$hash = 'sha256=' . hash_hmac('sha256', $payload, $secret);
if (!hash_equals($hash, $signature)) {
return response('Invalid signature', 401);
}
$event = $request->header('X-GitHub-Event');
$data = $request->json()->all();
// Handle the webhook
logger()->info("Received {$event} event", $data);
return response('OK', 200);
}
}
Python/Flask Example:
import hmac
import hashlib
from flask import request, abort
@app.route('/webhooks/github', methods=['POST'])
def github_webhook():
signature = request.headers.get('X-Hub-Signature-256')
if not signature:
abort(403)
secret = os.environ.get('GITHUB_WEBHOOK_SECRET').encode()
body = request.data
expected_signature = 'sha256=' + hmac.new(
secret, body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
abort(401)
event = request.headers.get('X-GitHub-Event')
payload = request.json
# Handle the webhook
app.logger.info(f'Received {event} event: {payload}')
return 'OK', 200
Stripe Webhooks
Stripe webhooks notify your application about payment events, subscription changes, customer updates, and more.
Setting Up Stripe Webhooks
- Start Jetty with a reserved subdomain:
jetty share 8000 --subdomain=stripe-webhooks
- Log in to your Stripe Dashboard
- Go to Developers -> Webhooks
- Click Add endpoint
- Configure:
- Endpoint URL:
https://stripe-webhooks.tunnels.usejetty.online/webhooks/stripe - Events to send: Select events (e.g.,
payment_intent.succeeded,customer.subscription.updated)
- Endpoint URL:
- Click Add endpoint
- Copy the Signing secret (starts with
whsec_) from the endpoint details page
Testing Payment Events
Create a test payment in Stripe:
# Using Stripe CLI
stripe payments create --amount=1000 --currency=usd
# Or use the Stripe Dashboard test mode
When the payment completes, Stripe sends a payment_intent.succeeded webhook to your local server.
Working with Stripe CLI
You can use both Stripe CLI and Jetty together:
- Stripe CLI: Test locally without exposing a public URL
- Jetty: Test with actual Stripe webhooks from the dashboard, including event ordering and retry behavior
To use both simultaneously:
# Terminal 1: Your application
npm start
# Terminal 2: Jetty tunnel for real Stripe events
jetty share 8000 --subdomain=stripe-webhooks
# Terminal 3: Stripe CLI for quick local testing
stripe listen --forward-to localhost:8000/webhooks/stripe
Verifying Stripe Signatures
Always verify Stripe webhook signatures in production:
Node.js/Express Example:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
console.log('PaymentIntent succeeded:', paymentIntent.id);
// Fulfill the order, send confirmation email, etc.
break;
case 'customer.subscription.updated':
const subscription = event.data.object;
console.log('Subscription updated:', subscription.id);
// Update user's subscription status in your database
break;
default:
console.log(`Unhandled event type ${event.type}`);
}
res.json({received: true});
});
PHP/Laravel Example:
use Stripe\Stripe;
use Stripe\Webhook;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function stripe(Request $request)
{
Stripe::setApiKey(config('services.stripe.secret'));
$payload = $request->getContent();
$sig = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $sig, $secret);
} catch (\UnexpectedValueException $e) {
return response('Invalid payload', 400);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
// Handle the event
match ($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event->data->object),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($event->data->object),
default => logger()->info("Unhandled event: {$event->type}"),
};
return response()->json(['received' => true]);
}
private function handlePaymentSucceeded($paymentIntent)
{
logger()->info("Payment succeeded: {$paymentIntent->id}");
// Fulfill order, send email, etc.
}
private function handleSubscriptionUpdated($subscription)
{
logger()->info("Subscription updated: {$subscription->id}");
// Update database
}
}
Python/Flask Example:
import stripe
from flask import request, jsonify
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
endpoint_secret = os.environ.get('STRIPE_WEBHOOK_SECRET')
@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.data
sig_header = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, endpoint_secret
)
except ValueError as e:
return 'Invalid payload', 400
except stripe.error.SignatureVerificationError as e:
return 'Invalid signature', 400
# Handle the event
if event['type'] == 'payment_intent.succeeded':
payment_intent = event['data']['object']
app.logger.info(f'PaymentIntent succeeded: {payment_intent["id"]}')
# Fulfill order
elif event['type'] == 'customer.subscription.updated':
subscription = event['data']['object']
app.logger.info(f'Subscription updated: {subscription["id"]}')
# Update database
else:
app.logger.info(f'Unhandled event type: {event["type"]}')
return jsonify(success=True)
Common Stripe Events
payment_intent.succeeded: Payment completed successfullypayment_intent.payment_failed: Payment failedcustomer.created: New customer createdcustomer.subscription.created: New subscription startedcustomer.subscription.updated: Subscription changedcustomer.subscription.deleted: Subscription canceledinvoice.paid: Invoice paidinvoice.payment_failed: Invoice payment failedcharge.refunded: Charge refunded
Twilio Webhooks
Twilio uses webhooks to notify your app about SMS replies, call status updates, and voice events.
Setting Up Twilio Webhooks
- Start your tunnel:
jetty share 8000 --subdomain=twilio-webhooks
- Log in to the Twilio Console
- Go to Phone Numbers -> Manage -> Active Numbers
- Click on your phone number
- Under "Messaging", set:
- A Message Comes In: Webhook,
https://twilio-webhooks.tunnels.usejetty.online/webhooks/twilio/sms - HTTP Method: POST
- A Message Comes In: Webhook,
- Under "Voice", set:
- A Call Comes In: Webhook,
https://twilio-webhooks.tunnels.usejetty.online/webhooks/twilio/voice - HTTP Method: POST
- A Call Comes In: Webhook,
Testing SMS Webhooks
Send an SMS to your Twilio number from your phone. Twilio sends a POST request with:
From: +15551234567
To: +15559876543
Body: Hello from my phone!
MessageSid: SM123456789abcdef
Responding with TwiML
Twilio expects TwiML (Twilio Markup Language) in the response:
Node.js/Express Example:
const twilio = require('twilio');
app.post('/webhooks/twilio/sms', (req, res) => {
const incomingMsg = req.body.Body;
const from = req.body.From;
console.log(`SMS from ${from}: ${incomingMsg}`);
const twiml = new twilio.twiml.MessagingResponse();
twiml.message('Thanks for your message! We received: ' + incomingMsg);
res.type('text/xml');
res.send(twiml.toString());
});
app.post('/webhooks/twilio/voice', (req, res) => {
const from = req.body.From;
console.log(`Call from ${from}`);
const twiml = new twilio.twiml.VoiceResponse();
twiml.say('Hello! Thank you for calling.');
twiml.play('https://demo.twilio.com/docs/classic.mp3');
res.type('text/xml');
res.send(twiml.toString());
});
PHP/Laravel Example:
use Twilio\TwiML\MessagingResponse;
use Twilio\TwiML\VoiceResponse;
class TwilioWebhookController extends Controller
{
public function sms(Request $request)
{
$from = $request->input('From');
$body = $request->input('Body');
logger()->info("SMS from {$from}: {$body}");
$response = new MessagingResponse();
$response->message("Thanks for your message! We received: {$body}");
return response($response, 200)->header('Content-Type', 'text/xml');
}
public function voice(Request $request)
{
$from = $request->input('From');
logger()->info("Call from {$from}");
$response = new VoiceResponse();
$response->say('Hello! Thank you for calling.');
$response->play('https://demo.twilio.com/docs/classic.mp3');
return response($response, 200)->header('Content-Type', 'text/xml');
}
}
Python/Flask Example:
from twilio.twiml.messaging_response import MessagingResponse
from twilio.twiml.voice_response import VoiceResponse
@app.route('/webhooks/twilio/sms', methods=['POST'])
def twilio_sms():
from_number = request.form.get('From')
body = request.form.get('Body')
app.logger.info(f'SMS from {from_number}: {body}')
resp = MessagingResponse()
resp.message(f'Thanks for your message! We received: {body}')
return str(resp), 200, {'Content-Type': 'text/xml'}
@app.route('/webhooks/twilio/voice', methods=['POST'])
def twilio_voice():
from_number = request.form.get('From')
app.logger.info(f'Call from {from_number}')
resp = VoiceResponse()
resp.say('Hello! Thank you for calling.')
resp.play('https://demo.twilio.com/docs/classic.mp3')
return str(resp), 200, {'Content-Type': 'text/xml'}
Validating Twilio Requests
Twilio signs all requests so you can verify they came from Twilio:
Node.js Example:
const twilio = require('twilio');
function validateTwilioRequest(req) {
const authToken = process.env.TWILIO_AUTH_TOKEN;
const twilioSignature = req.headers['x-twilio-signature'];
const url = `https://twilio-webhooks.tunnels.usejetty.online${req.originalUrl}`;
return twilio.validateRequest(authToken, twilioSignature, url, req.body);
}
app.post('/webhooks/twilio/sms', (req, res) => {
if (!validateTwilioRequest(req)) {
return res.status(403).send('Forbidden');
}
// Handle the webhook
});
Note: Twilio validation requires the full URL including the tunnel domain. In development, you might skip validation or configure it accordingly.
Shopify Webhooks
Shopify webhooks notify your app about store events: orders created, products updated, customers registered, and more.
Setting Up Shopify Webhooks
- Start your tunnel:
jetty share 8000 --subdomain=shopify-webhooks
- Log in to your Shopify Admin
- Go to Settings -> Notifications
- Scroll to Webhooks section
- Click Create webhook
- Configure:
- Event: Select event (e.g., "Order creation")
- Format: JSON
- URL:
https://shopify-webhooks.tunnels.usejetty.online/webhooks/shopify - Webhook API version: Latest stable version
- Click Save webhook
Alternatively, use the Shopify API or app configuration:
curl -X POST "https://{shop}.myshopify.com/admin/api/2024-01/webhooks.json" \
-H "X-Shopify-Access-Token: {access_token}" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"topic": "orders/create",
"address": "https://shopify-webhooks.tunnels.usejetty.online/webhooks/shopify",
"format": "json"
}
}'
Testing Order Webhooks
Create a test order in your Shopify store:
- Go to Orders in your Shopify admin
- Click Create order
- Add products, customer info, and complete the order
- Shopify immediately sends an
orders/createwebhook to your tunnel
Verifying Shopify HMAC
Shopify signs webhooks with an HMAC signature in the X-Shopify-Hmac-SHA256 header:
Node.js/Express Example:
const crypto = require('crypto');
function verifyShopifyWebhook(req, secret) {
const hmac = req.headers['x-shopify-hmac-sha256'];
if (!hmac) {
return false;
}
const hash = crypto
.createHmac('sha256', secret)
.update(req.body, 'utf8')
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(hmac),
Buffer.from(hash)
);
}
app.post('/webhooks/shopify', express.raw({type: 'application/json'}), (req, res) => {
const secret = process.env.SHOPIFY_WEBHOOK_SECRET;
if (!verifyShopifyWebhook(req, secret)) {
return res.status(401).send('Invalid signature');
}
const topic = req.headers['x-shopify-topic'];
const shop = req.headers['x-shopify-shop-domain'];
const payload = JSON.parse(req.body.toString());
console.log(`Webhook from ${shop}: ${topic}`, payload);
// Handle different webhook topics
switch (topic) {
case 'orders/create':
console.log('New order:', payload.id, payload.total_price);
break;
case 'products/update':
console.log('Product updated:', payload.id, payload.title);
break;
case 'customers/create':
console.log('New customer:', payload.email);
break;
}
res.status(200).send('OK');
});
PHP/Laravel Example:
use Illuminate\Http\Request;
class ShopifyWebhookController extends Controller
{
public function handle(Request $request)
{
$secret = config('services.shopify.webhook_secret');
$hmac = $request->header('X-Shopify-Hmac-SHA256');
if (!$hmac) {
return response('Forbidden', 403);
}
$data = $request->getContent();
$calculated = base64_encode(hash_hmac('sha256', $data, $secret, true));
if (!hash_equals($calculated, $hmac)) {
return response('Invalid signature', 401);
}
$topic = $request->header('X-Shopify-Topic');
$shop = $request->header('X-Shopify-Shop-Domain');
$payload = $request->json()->all();
logger()->info("Webhook from {$shop}: {$topic}", $payload);
// Handle different topics
match ($topic) {
'orders/create' => $this->handleOrderCreated($payload),
'products/update' => $this->handleProductUpdated($payload),
'customers/create' => $this->handleCustomerCreated($payload),
default => logger()->info("Unhandled topic: {$topic}"),
};
return response('OK', 200);
}
private function handleOrderCreated(array $order)
{
logger()->info("New order: {$order['id']} - {$order['total_price']}");
// Process order, send confirmation, etc.
}
}
Python/Flask Example:
import hmac
import hashlib
import base64
@app.route('/webhooks/shopify', methods=['POST'])
def shopify_webhook():
secret = os.environ.get('SHOPIFY_WEBHOOK_SECRET').encode()
hmac_header = request.headers.get('X-Shopify-Hmac-SHA256')
if not hmac_header:
abort(403)
body = request.data
calculated_hmac = base64.b64encode(
hmac.new(secret, body, hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(calculated_hmac, hmac_header):
abort(401)
topic = request.headers.get('X-Shopify-Topic')
shop = request.headers.get('X-Shopify-Shop-Domain')
payload = request.json
app.logger.info(f'Webhook from {shop}: {topic}')
if topic == 'orders/create':
app.logger.info(f'New order: {payload["id"]} - {payload["total_price"]}')
# Process order
elif topic == 'products/update':
app.logger.info(f'Product updated: {payload["id"]} - {payload["title"]}')
# Update product cache
return 'OK', 200
Common Shopify Webhook Topics
orders/create: New order placedorders/updated: Order details changedorders/paid: Order payment receivedorders/fulfilled: Order shippedproducts/create: New product addedproducts/update: Product details changedcustomers/create: New customer registeredcustomers/update: Customer details changedapp/uninstalled: App removed from store
Common Gotchas
Issue: Webhooks stop working after a while
Solution: Shopify automatically disables webhooks that fail repeatedly (19+ consecutive failures over 48 hours). Check your server logs and ensure your endpoint returns 200 OK quickly, even if background processing isn't complete.
Issue: Missing webhook events
Solution: Shopify doesn't guarantee webhook delivery. Implement idempotency and consider polling critical resources as a fallback.
Issue: Duplicate webhook deliveries
Solution: Shopify may send the same webhook multiple times. Use the X-Shopify-Webhook-Id header to detect and ignore duplicates:
const processedWebhooks = new Set();
app.post('/webhooks/shopify', (req, res) => {
const webhookId = req.headers['x-shopify-webhook-id'];
if (processedWebhooks.has(webhookId)) {
console.log('Duplicate webhook, ignoring');
return res.status(200).send('OK');
}
processedWebhooks.add(webhookId);
// Process webhook...
res.status(200).send('OK');
});
Advanced Topics
Webhook Signature Verification Deep Dive
Each provider uses a slightly different approach to signing webhooks. Understanding the details helps debug signature issues.
GitHub: HMAC-SHA256 with Prefix
GitHub sends X-Hub-Signature-256: sha256=<hex_digest>:
function verifyGitHub(body, signature, secret) {
// signature format: "sha256=abc123..."
const [algorithm, receivedHash] = signature.split('=');
if (algorithm !== 'sha256') {
return false;
}
const expectedHash = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(receivedHash),
Buffer.from(expectedHash)
);
}
Important: Use the raw body as a string or Buffer, not parsed JSON.
Stripe: Timestamp + Signature
Stripe prevents replay attacks by including a timestamp:
function verifyStripe(body, header, secret) {
// header format: "t=1234567890,v1=abc123...,v1=def456..."
const elements = header.split(',');
const timestamp = elements.find(e => e.startsWith('t='))?.split('=')[1];
const signatures = elements.filter(e => e.startsWith('v1=')).map(e => e.split('=')[1]);
// Create signed payload: timestamp.body
const signedPayload = `${timestamp}.${body}`;
// Compute expected signature
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Check if any signature matches
return signatures.some(sig =>
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))
);
}
Stripe also recommends checking timestamp freshness:
const MAX_AGE = 300; // 5 minutes
if (Math.abs(Date.now() / 1000 - timestamp) > MAX_AGE) {
throw new Error('Timestamp too old');
}
Shopify: Base64-encoded HMAC
Shopify sends X-Shopify-Hmac-SHA256: <base64_digest>:
function verifyShopify(body, hmac, secret) {
const expectedHmac = crypto
.createHmac('sha256', secret)
.update(body)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(hmac),
Buffer.from(expectedHmac)
);
}
Twilio: URL + Body Validation
Twilio signs the full URL plus sorted body parameters:
function verifyTwilio(url, params, signature, authToken) {
// Sort params alphabetically and concatenate
const data = url + Object.keys(params).sort().map(key =>
key + params[key]
).join('');
const expectedSig = crypto
.createHmac('sha1', authToken)
.update(Buffer.from(data, 'utf-8'))
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSig)
);
}
Note: Twilio validation requires the full public URL, including protocol and domain. This can be tricky in development--consider using the Twilio SDK's built-in validator.
Handling Idempotency
Webhook providers may deliver the same event multiple times due to:
- Network retries after timeout
- Provider infrastructure issues
- Your server returning non-2xx status
Strategies:
1. Event ID Deduplication
Store processed event IDs in a database or cache:
// Redis example
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
const key = `webhook:${event.id}`;
const exists = await redis.exists(key);
if (exists) {
return res.json({received: true, duplicate: true});
}
// Mark as processing
await redis.setex(key, 86400, '1'); // 24 hour TTL
// Process webhook
await handleEvent(event);
res.json({received: true});
});
2. Idempotent Operations
Design operations to be naturally idempotent:
// Bad - not idempotent
async function handlePaymentSuccess(paymentIntent) {
// Adds credits every time webhook is processed
await user.addCredits(paymentIntent.amount);
}
// Good - idempotent
async function handlePaymentSuccess(paymentIntent) {
// Only adds credits if not already processed
const existing = await payments.findOne({stripeId: paymentIntent.id});
if (existing && existing.credited) {
return; // Already processed
}
await user.addCredits(paymentIntent.amount);
await payments.updateOne(
{stripeId: paymentIntent.id},
{credited: true, creditedAt: new Date()}
);
}
3. Database Transactions
Use transactions to ensure atomic operations:
async function handleOrderCreated(order) {
await db.transaction(async (trx) => {
// Check if already processed
const existing = await trx('webhook_events')
.where({shopifyOrderId: order.id})
.first();
if (existing) {
return; // Rollback, don't process again
}
// Record webhook
await trx('webhook_events').insert({
shopifyOrderId: order.id,
processedAt: new Date()
});
// Process order
await trx('orders').insert({
shopifyOrderId: order.id,
customerEmail: order.customer.email,
totalPrice: order.total_price
});
// Send confirmation email
await sendOrderConfirmation(order);
});
}
Understanding Webhook Retry Logic
Most providers automatically retry failed webhooks, but the behavior varies:
GitHub
- Retries up to 3 times
- Exponential backoff
- Gives up after 3 failures
- Shows delivery attempts in webhook settings
Stripe
- Retries for up to 3 days
- Exponential backoff (1 hour, 2 hours, 4 hours, etc.)
- Automatically disables endpoint after too many failures
- You can manually retry from dashboard
Shopify
- Retries 19 times over 48 hours
- Automatically disables webhook after 19 consecutive failures
- Can't manually retry from dashboard
- Must trigger a new event
Twilio
- Retries up to 3 times
- 1-second delays between retries
- Gives up quickly (within seconds)
Best practices:
- Return
200 OKfor successfully received webhooks, even if processing fails - Use background jobs for processing so failures don't cause retries
- Monitor webhook failure rates
- Set up alerts when endpoints are auto-disabled
app.post('/webhooks/stripe', async (req, res) => {
let event;
try {
// Verify signature
event = stripe.webhooks.constructEvent(req.body, sig, secret);
} catch (err) {
// Signature verification failed - don't retry
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Signature valid - acknowledge receipt immediately
res.json({received: true});
// Process asynchronously - failures won't cause retries
setImmediate(async () => {
try {
await handleEvent(event);
} catch (err) {
// Log error, alert team, push to DLQ, etc.
console.error('Error processing webhook:', err);
await alerting.send({
level: 'error',
message: `Failed to process Stripe webhook: ${event.type}`,
error: err,
eventId: event.id
});
}
});
});
Related documentation
- Webhook testing -- general webhook testing workflow, debugging, and best practices
- 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
Send feedback
Found an issue or have a suggestion? Let us know.