Stripe Webhooks: The Definitive Guide to Local Development without Tunnels
Stop restarting ngrok and updating Stripe URLs. FetchHook provides a persistent, static mailbox for your Stripe webhooks that works anywhere—locally, in serverless, or behind firewalls.
The Stripe Primitive
# Configure Stripe to send to your FetchHook URL:
# https://api.fetchhook.app/in/stash_stripe_dev
# Then pull events whenever your script is ready:
curl https://api.fetchhook.app/api/v1/stash_stripe_dev \
-H "Authorization: Bearer fh_live_xxx"#How do I receive Stripe webhooks locally without ngrok?
Traditional local development requires ngrok to tunnel traffic to your machine. This is fragile: URLs change on restart, and your script must be running to receive events. FetchHook replaces the tunnel with a mailbox. Stripe 'pushes' to FetchHook (which responds in <100ms), and your local script 'pulls' from FetchHook whenever ready. No public IP, no changing URLs, and 24-hour persistence. Even if your laptop crashes or you close your IDE, the webhook is safely stored and waiting for you.
#How do I configure Stripe to send to FetchHook?
Setup takes 2 minutes: (1) Go to Stripe Dashboard → Developers → Webhooks, (2) Click 'Add endpoint', (3) Enter your FetchHook ingress URL: https://api.fetchhook.app/in/stash_stripe_dev, (4) Select events to listen for (payment_intent.succeeded, customer.created, etc.), (5) Click 'Add endpoint' and copy your webhook signing secret. That's it! Stripe will now send all selected events to FetchHook. Your script pulls them on-demand.
Stripe Dashboard Configuration
Stripe Dashboard → Developers → Webhooks → Add endpoint
Endpoint URL:
https://api.fetchhook.app/in/stash_stripe_dev
Events to send:
✓ payment_intent.succeeded
✓ payment_intent.payment_failed
✓ customer.subscription.created
✓ customer.subscription.deleted
✓ invoice.payment_succeeded
Signing secret (save this):
whsec_xxxxx...
FetchHook will verify this signature automatically.#How to handle Stripe signature verification in a pull model?
One of the biggest pains in webhook development is verifying signatures. Stripe signs every webhook with HMAC-SHA256 and a timestamp to prevent replay attacks. Implementing this correctly requires: raw body access (before JSON parsing), timestamp validation, constant-time comparison, secret management. FetchHook offloads this. When you pull events from our API, we've already validated the Stripe-Signature header at ingress using your webhook signing secret. You simply check the 'signature_verified' flag in the JSON response.
Python Implementation with Signature Check
import requests
import os
FETCHHOOK_API_KEY = os.getenv("FETCHHOOK_API_KEY")
SOURCE_ID = "stash_stripe_dev"
def process_stripe_payments():
# Pull all Stripe events from mailbox
response = requests.get(
f"https://api.fetchhook.app/api/v1/{SOURCE_ID}",
headers={"Authorization": f"Bearer {FETCHHOOK_API_KEY}"}
)
if response.status_code != 200:
print(f"Failed to fetch: {response.status_code}")
return
events = response.json().get('events', [])
for event in events:
# FetchHook already verified the signature
if not event.get('signature_verified'):
print(f"⚠️ Unverified event skipped: {event['id']}")
continue
# Safe to process - cryptographically verified
payload = event['payload']
event_type = payload.get('type')
if event_type == 'payment_intent.succeeded':
# Fulfill order
amount = payload['data']['object']['amount']
customer = payload['data']['object']['customer']
print(f"✓ Payment verified: ${amount/100} from {customer}")
fulfill_order(payload)
elif event_type == 'payment_intent.payment_failed':
# Handle failure
print(f"✗ Payment failed: {payload['id']}")
notify_customer_payment_failed(payload)
if __name__ == "__main__":
process_stripe_payments()#Which Stripe events should I listen for?
Most applications need 3-5 core events: (1) payment_intent.succeeded - Payment completed, fulfill order, (2) payment_intent.payment_failed - Retry payment or notify customer, (3) customer.subscription.created - New subscription started, (4) customer.subscription.deleted - Subscription canceled, remove access, (5) invoice.payment_succeeded - Recurring billing succeeded. For SaaS apps, add checkout.session.completed (one-time purchases). For marketplaces, add account.updated (Connect onboarding). Listen only to events you actually need—reduces noise and processing load.
Event Handler Pattern
// Node.js event handler pattern
const handlers = {
'payment_intent.succeeded': async (payload) => {
const { amount, customer } = payload.data.object;
await fulfillOrder(customer, amount);
await sendConfirmationEmail(customer);
},
'payment_intent.payment_failed': async (payload) => {
const { customer, last_payment_error } = payload.data.object;
await notifyPaymentFailed(customer, last_payment_error);
},
'customer.subscription.created': async (payload) => {
const { customer, items } = payload.data.object;
await provisionSubscription(customer, items);
},
'customer.subscription.deleted': async (payload) => {
const { customer } = payload.data.object;
await revokeAccess(customer);
}
};
async function processStripeEvent(event) {
const eventType = event.payload.type;
const handler = handlers[eventType];
if (handler) {
await handler(event.payload);
} else {
console.log(`Unhandled event: ${eventType}`);
}
}#How do I test Stripe webhooks locally?
Testing is easy with FetchHook: (1) Use Stripe CLI to trigger test events: stripe trigger payment_intent.succeeded, (2) Or use Stripe Dashboard to send test webhooks manually, (3) Or create test transactions in Stripe test mode. All webhooks go to your FetchHook mailbox. Your local script pulls them and processes in your development environment. No ngrok restart, no URL reconfiguration. You can even trigger multiple test events, then pull them all at once to test batch processing.
Local Testing Workflow
# Terminal 1: Send test events with Stripe CLI
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
stripe trigger payment_intent.payment_failed
# Terminal 2: Pull and process in your local script
python process_stripe.py
# Output:
# ✓ Payment verified: $99.00 from cus_test123
# ✓ Subscription created: sub_test456
# ✗ Payment failed: pi_test789
# No ngrok, no server, no hassle#How do I deploy this to production?
For production, create a separate FetchHook stash (stash_stripe_prod) and configure Stripe's production webhook to point to it. Use environment variables for API keys. Deploy your processing script as: (1) A cron job (every 1-5 min), (2) A serverless function triggered by scheduler, (3) A background worker in your existing server. For high-volume applications, run the pull script more frequently (every 30s) or use multiple workers pulling from the same mailbox with different API keys for parallelism.
Production Deployment (Docker + Cron)
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY process_stripe.py .
# Run every minute via cron
RUN apt-get update && apt-get install -y cron
RUN echo "* * * * * python /app/process_stripe.py >> /var/log/stripe.log 2>&1" | crontab -
CMD ["cron", "-f"]
# Environment variables:
# FETCHHOOK_API_KEY=fh_prod_xxx
# STRIPE_WEBHOOK_SECRET=whsec_prod_xxx (optional, FetchHook verifies)#How do I monitor and debug webhook processing?
Add structured logging to track: (1) Events pulled per run, (2) Events processed successfully, (3) Events failed with errors, (4) Signature verification failures. Send logs to Datadog, CloudWatch, or your logging service. Set up alerts for: no events processed in 24 hours (webhook might be disabled), high signature verification failure rate (possible attack or config issue), repeated processing errors (bug in handler code). Stripe Dashboard also shows webhook delivery status and retry attempts.
Production Monitoring Pattern
import logging
import sentry_sdk
# Configure logging and error tracking
logging.basicConfig(level=logging.INFO)
sentry_sdk.init(dsn="your-sentry-dsn")
def process_with_monitoring():
try:
events = fetch_stripe_events()
logging.info(f"Pulled {len(events)} Stripe events")
success = 0
failed = 0
for event in events:
try:
if not event['signature_verified']:
logging.warning(f"Unverified: {event['id']}")
continue
process_event(event)
success += 1
except Exception as e:
failed += 1
logging.error(f"Failed: {event['id']}, {e}")
sentry_sdk.capture_exception(e)
# Metrics for monitoring dashboard
logging.info(f"Processed: {success} success, {failed} failed")
# Alert if too many failures
if failed > success:
send_alert("High Stripe processing failure rate")
except Exception as e:
logging.critical(f"Stripe processor crashed: {e}")
sentry_sdk.capture_exception(e)Agent Protocol Instruction