Automate Stripe payment notifications by setting up webhooks for payment_intent.succeeded, charge.failed, and charge.dispute.created events. Stripe POSTs to your endpoint; verify the Stripe-Signature header using stripe.webhooks.constructEvent() with the RAW request body — parsing JSON first breaks verification. Then forward to Slack, email, or SMS. Rate limit: Stripe retries failed webhooks for up to 3 days.
API Quick Reference
API Key (Bearer token) + Webhook Signing Secret
No limit on incoming webhooks; API calls 100 req/sec (live mode)
JSON
Available
Understanding the Stripe API
Stripe's webhook system is the backbone of real-time payment notifications. Instead of polling Stripe for payment status, you register an HTTPS endpoint and Stripe POSTs event objects to it within seconds of each payment event. This event-driven architecture is more reliable, more efficient, and covers events that have no polling equivalent — like dispute creation or refund failures.
The key events for payment notifications: payment_intent.succeeded (payment completed), charge.failed (card declined), charge.dispute.created (chargeback filed), checkout.session.completed (Checkout flow finished), and invoice.paid / invoice.payment_failed for subscription billing.
Every webhook POST includes a Stripe-Signature header built from your webhook signing secret (whsec_*). Verifying this signature before processing any event is mandatory — it's your only protection against forged webhook requests. The #1 implementation error is calling JSON.parse() or request.json on the body before passing it to constructEvent(), which corrupts the signature verification. Official documentation: https://stripe.com/docs/webhooks
https://api.stripe.comSetting Up Stripe API Authentication
Payment notifications require two credentials: your API secret key (sk_*) for making API calls, and a webhook signing secret (whsec_*) for verifying incoming webhook events. These are separate — the signing secret is specific to each webhook endpoint you register in Stripe.
- 1Log in to your Stripe Dashboard at dashboard.stripe.com
- 2Go to Developers > API keys and copy your secret key (sk_live_* for production)
- 3Set STRIPE_SECRET_KEY environment variable
- 4Go to Developers > Webhooks > Add endpoint
- 5Enter your endpoint URL (must be HTTPS in production) and select events: payment_intent.succeeded, charge.failed, charge.dispute.created, checkout.session.completed
- 6Click 'Add endpoint' — Stripe creates the webhook and shows a Signing Secret (whsec_...)
- 7Set STRIPE_WEBHOOK_SECRET environment variable with the whsec_* value
- 8For local development, use Stripe CLI: stripe listen --forward-to localhost:3000/webhooks/stripe
1import os2import stripe34stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')5WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')67# List registered webhook endpoints8endpoints = stripe.WebhookEndpoint.list(limit=5)9for ep in endpoints.data:10 print(f'Endpoint: {ep.url}')11 print(f'Events: {ep.enabled_events}')12 print(f'Status: {ep.status}')Security notes
- •Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in environment variables — never hardcode
- •Each webhook endpoint has its own signing secret — use the correct secret for each endpoint
- •Always verify the Stripe-Signature header before processing any webhook event
- •Use the raw request body for signature verification — never parse JSON first
- •Reject events older than 5 minutes (Stripe SDK does this by default) to prevent replay attacks
- •Never expose your webhook signing secret (whsec_*) to frontend code or logs
Key endpoints
/v1/webhook_endpointsProgrammatically registers a webhook endpoint. Alternatively, register manually in the Stripe Dashboard. Returns the endpoint object including the signing secret.
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | required | HTTPS URL of your webhook endpoint. Must be publicly accessible (not localhost). |
enabled_events | array | required | List of event types to send to this endpoint. Use ['*'] to receive all events. |
Request
1{"url": "https://yourapp.com/webhooks/stripe", "enabled_events": ["payment_intent.succeeded", "charge.failed", "charge.dispute.created", "checkout.session.completed"]}Response
1{"id": "we_1OqKLt2eZvKYlo2C", "object": "webhook_endpoint", "url": "https://yourapp.com/webhooks/stripe", "status": "enabled", "enabled_events": ["payment_intent.succeeded", "charge.failed", "charge.dispute.created"], "secret": "whsec_AbCdEfGhIjKlMnOpQrStUv"}/v1/events/{id}Retrieves a specific event by ID. Useful for reprocessing missed events or debugging webhook handlers without waiting for Stripe to resend.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | required | The event ID (evt_*) to retrieve |
Response
1{"id": "evt_1OqKLt2eZvKYlo2C", "object": "event", "type": "payment_intent.succeeded", "created": 1706745700, "livemode": true, "data": {"object": {"id": "pi_1OqKLt", "amount": 2000, "currency": "usd", "status": "succeeded", "customer": "cus_PqKLt2", "metadata": {"order_id": "ORD-881"}}}}/v1/eventsLists events with optional filtering by type and date. Use this for backfilling missed events or debugging. Events are retained for 30 days.
| Parameter | Type | Required | Description |
|---|---|---|---|
type | string | optional | Filter by event type, e.g. 'payment_intent.succeeded' |
created[gte] | number | optional | Unix timestamp — return events after this time |
limit | number | optional | Records per page, max 100 |
Response
1{"object": "list", "data": [{"id": "evt_1OqKLt", "type": "payment_intent.succeeded", "created": 1706745700}], "has_more": true}/v1/webhook_endpoints/{id}Updates a webhook endpoint — change the URL, add/remove event subscriptions, or enable/disable it.
| Parameter | Type | Required | Description |
|---|---|---|---|
enabled_events | array | optional | Updated list of event types for this endpoint |
url | string | optional | New URL for the endpoint |
disabled | boolean | optional | Set to true to temporarily disable the endpoint without deleting it |
Request
1{"enabled_events": ["payment_intent.succeeded", "charge.failed", "charge.dispute.created", "checkout.session.completed", "invoice.paid"]}Response
1{"id": "we_1OqKLt2eZvKYlo2C", "status": "enabled", "enabled_events": ["payment_intent.succeeded", "charge.failed", "charge.dispute.created", "checkout.session.completed", "invoice.paid"]}Step-by-step automation
Set Up the Webhook Endpoint with Signature Verification
Why: Signature verification is the security foundation — without it, anyone can POST fake payment events to your endpoint and trigger your business logic.
Register your HTTPS endpoint in the Stripe Dashboard for the events you care about. In your handler, use express.raw() or equivalent to preserve the raw request body, then call stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) before touching any event data. If verification fails, return 400 immediately.
1# Register webhook endpoint via API2curl https://api.stripe.com/v1/webhook_endpoints \3 -u "$STRIPE_SECRET_KEY:" \4 -d url="https://yourapp.com/webhooks/stripe" \5 -d "enabled_events[]"="payment_intent.succeeded" \6 -d "enabled_events[]"="charge.failed" \7 -d "enabled_events[]"="charge.dispute.created" \8 -d "enabled_events[]"="checkout.session.completed"Pro tip: Run stripe listen --forward-to localhost:3000/webhooks/stripe with the Stripe CLI during development to test webhooks without needing a public URL. It also shows all event payloads in real-time.
Expected result: Incoming Stripe webhooks are verified and the event object is available for processing. Forged or corrupted requests return 400.
Route Events and Send Notifications
Why: Different payment events require different notification actions — successes go to Slack/email, failures trigger customer support alerts, disputes require immediate attention.
Switch on event.type to route each event to the correct notification handler. The data.object field contains the full payment object (PaymentIntent, Charge, or Dispute). Extract the amount, customer email, and metadata you stored on the payment to build meaningful notification messages.
1# Send a Slack notification (use from within your webhook handler)2curl -X POST $SLACK_WEBHOOK_URL \3 -H 'Content-Type: application/json' \4 -d '{"text": "Payment succeeded: USD 29.00 from customer@example.com"}'Pro tip: Use different Slack channels for different event types: #payments for successes, #payment-failures for declines, #disputes for chargebacks. Disputes need immediate attention — send them to a PagerDuty or phone alert channel.
Expected result: Slack (or email/SMS) receives formatted notifications for each payment event type within seconds of the event occurring.
Handle Webhook Retries Idempotently
Why: Stripe retries webhooks for up to 3 days on failures — your handler must be idempotent to avoid processing the same payment event twice.
Store processed event IDs in your database or cache. Before processing, check if event.id has already been handled. Stripe guarantees event.id is unique per event — using it as an idempotency key prevents double-notifications, double-grants, and double-database writes when Stripe retries.
1# Retrieve a specific event to manually replay it2curl https://api.stripe.com/v1/events/evt_1OqKLt2eZvKYlo2C \3 -u "$STRIPE_SECRET_KEY:"Pro tip: Store processed event IDs in Redis with a 24-hour TTL using SET ... NX EX 86400. The NX flag (only set if not exists) is atomic — no race conditions in concurrent webhook processing.
Expected result: Duplicate webhook deliveries (from Stripe retries) are safely ignored without triggering duplicate notifications or database writes.
Backfill Missed Events
Why: If your webhook endpoint was down, Stripe retries for 3 days — but after that, events are lost. Use GET /v1/events to recover them.
Query GET /v1/events with a created[gte] timestamp from when your endpoint went down. Filter by event type to get only the events you need to reprocess. This works for up to 30 days back — Stripe retains events for 30 days. Pass each event through your handler to backfill any missed notifications or database updates.
1# Get all payment_intent.succeeded events from the last 24 hours2curl "https://api.stripe.com/v1/events?type=payment_intent.succeeded&created[gte]=1706659200&limit=100" \3 -u "$STRIPE_SECRET_KEY:"Pro tip: Your idempotency check (step 3) protects against reprocessing events that were already handled before the outage — run backfill without worrying about duplicates.
Expected result: All payment events from the specified time window are retrieved and processed, backfilling any notifications or database updates that were missed during downtime.
Complete working code
A complete Stripe webhook handler that verifies signatures, routes events to notification channels, handles idempotency with Redis, and responds within Stripe's 30-second timeout. Includes handlers for payments, failures, and disputes.
1import stripe2import os3import logging4import requests5from flask import Flask, request, jsonify67logging.basicConfig(level=logging.INFO)8log = logging.getLogger(__name__)910stripe.api_key = os.environ['STRIPE_SECRET_KEY']11WEBHOOK_SECRET = os.environ['STRIPE_WEBHOOK_SECRET']12SLACK_PAYMENTS = os.environ.get('SLACK_PAYMENTS_WEBHOOK')13SLACK_DISPUTES = os.environ.get('SLACK_DISPUTES_WEBHOOK')1415app = Flask(__name__)1617# Simple in-memory dedup (use Redis in production)18processed = set()1920def slack(webhook_url, message):21 if webhook_url:22 try:23 requests.post(webhook_url, json={'text': message}, timeout=5)24 except Exception as e:25 log.error(f'Slack failed: {e}')2627def handle_event(event):28 if event.id in processed:29 log.info(f'Duplicate: {event.id}')30 return31 processed.add(event.id)3233 obj = event.data.object3435 if event.type == 'payment_intent.succeeded':36 amt = f"{obj.currency.upper()} {obj.amount/100:.2f}"37 email = obj.get('receipt_email') or obj.metadata.get('email', 'unknown')38 order = obj.metadata.get('order_id', '')39 msg = f'Payment succeeded: {amt} from {email}' + (f' — Order {order}' if order else '')40 log.info(msg)41 slack(SLACK_PAYMENTS, msg)4243 elif event.type == 'charge.failed':44 amt = f"{obj.currency.upper()} {obj.amount/100:.2f}"45 reason = obj.failure_message or obj.failure_code or 'card_declined'46 msg = f'Payment FAILED: {amt} — {reason}'47 log.warning(msg)48 slack(SLACK_PAYMENTS, msg)4950 elif event.type == 'charge.dispute.created':51 amt = f"{obj.currency.upper()} {obj.amount/100:.2f}"52 msg = f'DISPUTE ALERT: {amt} — Reason: {obj.reason}. Deadline: respond within 7 days!'53 log.error(msg)54 slack(SLACK_DISPUTES, msg)5556 elif event.type == 'checkout.session.completed':57 total = f"{obj.currency.upper()} {obj.amount_total/100:.2f}"58 msg = f'Checkout completed: {obj.customer_email} — {total}'59 log.info(msg)60 slack(SLACK_PAYMENTS, msg)6162@app.route('/webhooks/stripe', methods=['POST'])63def webhook():64 payload = request.get_data() # Raw bytes65 sig = request.headers.get('Stripe-Signature')6667 try:68 event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)69 except Exception as e:70 log.warning(f'Webhook rejected: {e}')71 return jsonify({'error': str(e)}), 4007273 try:74 handle_event(event)75 except Exception as e:76 log.error(f'Handler error for {event.id}: {e}')77 return jsonify({'error': 'Processing failed'}), 5007879 return jsonify({'received': True})8081if __name__ == '__main__':82 app.run(port=3000)Error handling
No signatures found matching the expected signature for payloadThe request body was parsed (JSON.parse or request.json) before being passed to constructEvent(). Parsing modifies the raw bytes and breaks HMAC verification.
Use express.raw({type: 'application/json'}) in Node.js or request.get_data() in Flask. The webhook route must receive the raw body bytes. If using body-parser globally, define the webhook route before the body-parser middleware.
No retry — fix the middleware configuration
Timestamp outside the tolerance zoneThe webhook event timestamp is older than 5 minutes (Stripe SDK default tolerance). This protects against replay attacks. Can also happen if your server clock is wrong.
Ensure your server's system clock is synchronized with NTP. If you need to extend tolerance for testing, pass tolerance parameter to constructEvent(). Never disable tolerance entirely in production.
No retry — fix the clock or adjust tolerance for legitimate cases
Stripe retries webhook — HTTP 500 response from your endpointYour webhook handler throws an exception or returns a 5xx status. Stripe treats this as a failed delivery and retries.
Return 200 immediately and process events asynchronously using a job queue. If synchronous processing is required, implement robust error handling so your endpoint always returns 2xx even on internal errors.
Stripe retries with exponential backoff for up to 3 days. After 3 days, events are no longer retried but remain accessible via GET /v1/events for 30 days.
Request timed out — no 2xx within 30 secondsYour webhook handler takes longer than 30 seconds to process the event. Stripe treats this as a failure and retries.
Return 200 within 1-2 seconds, then process asynchronously. Add the event to a job queue (Celery task, BullMQ job, or AWS SQS) and handle the actual notification/database update in a background worker.
Same as 500 — Stripe retries for up to 3 days
Invalid API key providedWrong STRIPE_SECRET_KEY when calling Stripe API endpoints (e.g., to retrieve event details or register webhook endpoints).
Verify the API key in Stripe Dashboard > Developers > API keys. Check that STRIPE_SECRET_KEY is set correctly in your environment.
No retry — fix the key
Rate Limits for Stripe API
| Scope | Limit | Window |
|---|---|---|
| API calls (live mode) | 100 requests | per second |
| API calls (test mode) | 25 requests | per second |
| Webhook delivery | Stripe retries | for up to 3 days on failure |
1import time2import stripe34def stripe_with_retry(fn, max_retries=5):5 for attempt in range(max_retries):6 try:7 return fn()8 except stripe.error.RateLimitError:9 wait = min(2 ** attempt, 32)10 print(f'Rate limit, waiting {wait}s')11 time.sleep(wait)12 raise Exception('Max retries exceeded')- Return 200 from webhook endpoints within 1-2 seconds — process events asynchronously via job queues to stay well under the 30-second timeout
- Deploy your webhook endpoint with a health check and monitoring alert — downtime causes Stripe to queue retries for 3 days, creating a burst when you come back online
- Use idempotency keys stored in Redis (SET NX with 24h TTL) to handle the retry burst safely without duplicate processing
- Subscribe only to event types you actually handle — unused event subscriptions add noise without value
- Use the Stripe CLI (stripe listen) for local development instead of setting up ngrok tunnels
Security checklist
- Verify every incoming webhook signature with stripe.webhooks.constructEvent() using the raw request body
- Store STRIPE_WEBHOOK_SECRET (whsec_*) in environment variables — never in source code
- Use express.raw() or equivalent for webhook routes — never parse the body first
- Reject events with timestamps older than 5 minutes (Stripe SDK enforces this by default — don't override it)
- Implement idempotency for all webhook handlers to handle retry deliveries safely
- Use HTTPS for your webhook endpoint URL — Stripe will not deliver to HTTP endpoints in production
- Log rejected webhook attempts (wrong signature) with IP address for security monitoring
- Return 200 for all valid events, even if you don't process the event type — returning 4xx causes unnecessary retries
Automation use cases
Slack Payment Dashboard
beginnerRoute all payment events to a dedicated Slack channel — successes to #payments, failures to #payment-failures, disputes to #disputes with @channel mention for urgency.
Email Receipt and Confirmation
beginnerTrigger transactional email via SendGrid or Resend on payment_intent.succeeded — send order confirmation with receipt details extracted from the payment intent metadata.
Dispute Management Alert
intermediateOn charge.dispute.created, immediately create a support ticket in your CRM, assign it to the payments team, and send an SMS alert — disputes have hard 7-day response deadlines.
Real-Time Revenue Dashboard Update
advancedUpdate a live revenue dashboard in real-time by pushing payment events to a WebSocket or SSE endpoint whenever payment_intent.succeeded fires.
No-code alternatives
Don't want to write code? These platforms can automate the same workflows visually.
Zapier
Free tier (100 tasks/month), paid from $19.99/monthZapier's Stripe integration listens for payment events and can forward them to Slack, Gmail, Airtable, or 200+ other apps without writing any webhook handling code.
- + No webhook code required
- + Hundreds of notification destination apps
- + 5-minute setup
- - No raw webhook signature verification
- - Limited event filtering logic
- - Task costs at high payment volume
Make
Free tier (1,000 ops/month), paid from $9/monthMake's Stripe webhook trigger handles signature verification automatically, with powerful routing logic to send different events to different destinations.
- + Visual routing between payment events
- + Better conditional logic than Zapier
- + Handles multiple notification channels
- - Monthly operation limits
- - Setup more complex than Zapier
- - Less Stripe-specific documentation
n8n
Free (self-hosted), Cloud from €20/monthn8n's Stripe trigger node handles webhook verification and exposes the full event payload, with nodes to route to Slack, email, SMS, or custom HTTP endpoints.
- + Full event payload access
- + Self-hostable for data privacy
- + Stripe trigger node handles signature verification
- - Requires self-hosted infrastructure for free tier
- - More setup than Zapier/Make
- - Node-based for more complex routing
Best practices
- Always verify webhook signatures before accessing event data — treat unverified webhooks as potential attacks
- Use the raw request body (bytes) for verification — never the parsed JSON object
- Return 200 within seconds and process asynchronously — Stripe considers your endpoint failed after 30 seconds
- Implement idempotency using event.id as a deduplication key — Stripe may deliver the same event multiple times
- Log the full event.id and event.type for every webhook received to enable debugging and audit trails
- Subscribe to charge.dispute.created and monitor it closely — disputes have 7-day response deadlines and evidence must be submitted promptly
- Use the Stripe Dashboard's webhook delivery logs to see which events succeeded or failed and trigger manual retries
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building a Stripe webhook handler in Node.js with Express. I use stripe.webhooks.constructEvent(req.body, sig, webhookSecret) where req.body comes from express.raw(). Help me: (1) confirm the express middleware setup is correct for signature verification, (2) add idempotency checking using Redis SET NX so duplicate event deliveries are ignored, (3) handle payment_intent.succeeded, charge.failed, and charge.dispute.created events with formatted Slack notifications, and (4) implement async processing that returns 200 immediately then processes the event in a background job.
Build a real-time payment notification dashboard in React with these features: (1) a live feed of recent payment events (succeeded, failed, disputed) updating in real-time via polling every 10 seconds from a backend API, (2) each event displayed as a card with amount, customer email, event type (color-coded green/red/orange), and timestamp, (3) a summary bar showing today's total successful payments, failed count, and dispute count, (4) a filter to show only specific event types, (5) a backend API endpoint GET /api/payments/recent that returns the last 50 events from Stripe's events API.
Frequently asked questions
Why does my webhook signature verification keep failing?
The most common cause: the request body was parsed as JSON before calling constructEvent(). In Express.js, if express.json() middleware runs before your webhook route, it parses the body and the raw bytes are lost. Fix: use express.raw({type: 'application/json'}) specifically for the webhook route, and define it before any global JSON parsing middleware. In Flask, use request.get_data() (returns bytes) not request.json (returns dict).
What happens if my webhook endpoint is down?
Stripe retries failed webhook deliveries (non-2xx response or timeout) for up to 3 days using exponential backoff. Starting intervals: immediate, +5 minutes, +30 minutes, +2 hours, +5 hours, growing from there. After 3 days, retries stop. Events remain retrievable via GET /v1/events for 30 days — use the backfill pattern in Step 4 to reprocess missed events.
Can I receive a webhook event more than once?
Yes. Stripe guarantees at-least-once delivery — the same event can be delivered multiple times, especially when your endpoint returns a 5xx or times out. Always implement idempotency using event.id as a deduplication key. Store processed event IDs in Redis (SET with NX flag and TTL) or a database unique constraint.
What event should I use for one-time Stripe Checkout payments vs subscription renewals?
For one-time Checkout payments, use checkout.session.completed. For subscription renewals, use invoice.paid (payment succeeded) and invoice.payment_failed (payment failed). For custom payment flows using Payment Intents directly, use payment_intent.succeeded. Don't use charge.succeeded — it's a lower-level event that fires for many charge types and is harder to filter correctly.
How do I test webhooks locally during development?
Use the Stripe CLI: run stripe listen --forward-to localhost:3000/webhooks/stripe. This creates a secure tunnel and forwards all events to your local server, showing you the exact payloads. The CLI also provides a webhook signing secret that you set as STRIPE_WEBHOOK_SECRET for local testing. Alternatively, trigger test events from your Dashboard under Developers > Webhooks > Send test webhook.
How urgent are chargeback (dispute) notifications?
Very urgent. When charge.dispute.created fires, you have 7 days to respond with evidence via the Stripe Dashboard or API. Missing this deadline results in automatic loss of the dispute and the charged-back amount plus a $15 dispute fee. Set up immediate alerting for dispute events — dedicated Slack channel, PagerDuty, or SMS alert.
Can RapidDev help build a custom payment notification and monitoring system?
Yes. RapidDev has built 600+ apps including real-time payment dashboards, dispute management workflows, and multi-channel notification systems integrated with Stripe. Get a free consultation at rapidevelopers.com.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation