Documentation for Jetty

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

  1. 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
  1. Go to your GitHub repository
  2. Click Settings -> Webhooks -> Add webhook
  3. 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")
  4. 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 delivery
  • X-Hub-Signature-256: HMAC signature for verifying the webhook is from GitHub
  • User-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

  1. Start Jetty with a reserved subdomain:
jetty share 8000 --subdomain=stripe-webhooks
  1. Log in to your Stripe Dashboard
  2. Go to Developers -> Webhooks
  3. Click Add endpoint
  4. Configure:
    • Endpoint URL: https://stripe-webhooks.tunnels.usejetty.online/webhooks/stripe
    • Events to send: Select events (e.g., payment_intent.succeeded, customer.subscription.updated)
  5. Click Add endpoint
  6. 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 successfully
  • payment_intent.payment_failed: Payment failed
  • customer.created: New customer created
  • customer.subscription.created: New subscription started
  • customer.subscription.updated: Subscription changed
  • customer.subscription.deleted: Subscription canceled
  • invoice.paid: Invoice paid
  • invoice.payment_failed: Invoice payment failed
  • charge.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

  1. Start your tunnel:
jetty share 8000 --subdomain=twilio-webhooks
  1. Log in to the Twilio Console
  2. Go to Phone Numbers -> Manage -> Active Numbers
  3. Click on your phone number
  4. Under "Messaging", set:
    • A Message Comes In: Webhook, https://twilio-webhooks.tunnels.usejetty.online/webhooks/twilio/sms
    • HTTP Method: POST
  5. Under "Voice", set:
    • A Call Comes In: Webhook, https://twilio-webhooks.tunnels.usejetty.online/webhooks/twilio/voice
    • HTTP Method: POST

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

  1. Start your tunnel:
jetty share 8000 --subdomain=shopify-webhooks
  1. Log in to your Shopify Admin
  2. Go to Settings -> Notifications
  3. Scroll to Webhooks section
  4. Click Create webhook
  5. 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
  6. 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:

  1. Go to Orders in your Shopify admin
  2. Click Create order
  3. Add products, customer info, and complete the order
  4. Shopify immediately sends an orders/create webhook 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 placed
  • orders/updated: Order details changed
  • orders/paid: Order payment received
  • orders/fulfilled: Order shipped
  • products/create: New product added
  • products/update: Product details changed
  • customers/create: New customer registered
  • customers/update: Customer details changed
  • app/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 OK for 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
      });
    }
  });
});

Send feedback

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