Stripe Webhook Events for SaaS
The events a subscription business actually needs, what each one means, what to do when it arrives, and what the payloads look like.
Checkout events
Sent when a customer finishes, or abandons, a Stripe Checkout session.
| Event | Sent when | What to do |
|---|---|---|
| checkout.session.completed | The customer completed checkout. For subscriptions the first invoice is already paid when the card succeeded. | Link the Stripe customer and subscription to your user, and record which referral or campaign the checkout carried. |
| checkout.session.async_payment_succeeded | A delayed payment method (for example a bank debit) used at checkout has now succeeded. | Fulfil orders that were waiting for the money. |
| checkout.session.async_payment_failed | That delayed payment failed. | Tell the customer and cancel the pending order. |
| checkout.session.expired | The session expired without a payment. | Optional: send an abandoned-checkout reminder. |
Subscriptions events
Most subscription activity is asynchronous, so these are the events that keep your copy of a subscription in sync.
| Event | Sent when | What to do |
|---|---|---|
| customer.subscription.created | A subscription was created. Its status may be incomplete if the first payment still needs authentication. | Store the subscription; do not grant access on this event alone. |
| customer.subscription.updated | A subscription started or changed: renewal, plan change, coupon, status change, cancellation scheduled. | Sync status, plan and cancel_at_period_end. data.previous_attributes shows what changed. |
| customer.subscription.deleted | The subscription ended. | Revoke access. |
| customer.subscription.trial_will_end | Three days before a trial ends (immediately if the trial is shorter). | Check there is a payment method and remind the customer. |
| customer.subscription.paused | The status changed to paused, for example a trial ended without a payment method. | Limit access until the subscription resumes. |
| customer.subscription.resumed | A paused subscription was resumed. | Restore access. |
Invoices events
Every subscription payment, the first and each renewal, goes through an invoice.
| Event | Sent when | What to do |
|---|---|---|
| invoice.paid | The invoice was paid. | Provision access or extend the access period, after confirming the subscription status is active. |
| invoice.payment_failed | A payment for an invoice failed. | Notify the customer and ask for a new payment method; consider Smart Retries. |
| invoice.payment_action_required | The payment needs customer authentication (3D Secure). | Send the customer back to complete it. |
| invoice.upcoming | A few days before a renewal, as set in your Billing settings. | Add extra invoice items or warn the customer of the charge. |
| invoice.created | An invoice was created for a new or renewing subscription. | Respond quickly: if Stripe doesn't get a success response, automatic finalization waits up to 72 hours. |
| invoice.finalization_failed | The invoice could not be finalized, so no payment can be collected. | Read last_finalization_error; with Stripe Tax, check automatic_tax.status. |
Refunds, disputes and fraud events
Easy to forget, and the reason a refunded or disputed customer can keep their access.
| Event | Sent when | What to do |
|---|---|---|
| charge.refunded | A charge was refunded, fully or partly. | Reverse what the payment granted: access, credits, an affiliate commission. |
| charge.dispute.created | The customer's bank opened a dispute. | Decide whether to submit evidence and whether to restrict the account. |
| radar.early_fraud_warning.created | The card issuer flagged the charge as potentially fraudulent, usually before a dispute. | Consider refunding proactively to avoid the dispute fee. |
| payment_intent.succeeded | A PaymentIntent completed. Fires for one-time payments outside Checkout too. | Fulfil one-time purchases made with your own payment form. |
Which event should grant access?
Use invoice.paid for subscriptions: it fires for the first payment and every renewal. Before extending access, retrieve the subscription and confirm its status is active, because a paid invoice does not always move a subscription to active. Revoke access on customer.subscription.deleted, and when the status becomes canceled or unpaid.
checkout.session.completed is the right moment to connect the Stripe customer to your user, but not on its own a reliable access signal for renewals.
Webhooks can arrive more than once and out of order. Store the event IDs you have processed and skip repeats, and return a 2xx quickly before doing slow work.
Example payloads
Shortened to the fields most integrations read; real events carry many more. Shapes follow API version 2025-03-31.basil and later, where an invoice’s subscription moved from invoice.subscription to invoice.parent.subscription_details.subscription and billing periods moved to subscription items.
checkout.session.completed
{
"id": "evt_1Q...",
"object": "event",
"type": "checkout.session.completed",
"livemode": false,
"data": {
"object": {
"id": "cs_test_a1...",
"object": "checkout.session",
"mode": "subscription",
"status": "complete",
"payment_status": "paid",
"amount_total": 4900,
"currency": "usd",
"customer": "cus_...",
"customer_details": { "email": "jenny@example.com" },
"subscription": "sub_...",
"client_reference_id": "user_123",
"metadata": { "referral_code": "PARTNER42" }
}
}
}invoice.paid (a renewal)
{
"type": "invoice.paid",
"data": {
"object": {
"id": "in_...",
"object": "invoice",
"status": "paid",
"billing_reason": "subscription_cycle",
"amount_paid": 4900,
"currency": "usd",
"customer": "cus_...",
"customer_email": "jenny@example.com",
"parent": {
"type": "subscription_details",
"subscription_details": {
"subscription": "sub_...",
"metadata": { "referral_code": "PARTNER42" }
}
}
}
}
}customer.subscription.updated (cancellation scheduled)
{
"type": "customer.subscription.updated",
"data": {
"object": {
"id": "sub_...",
"object": "subscription",
"status": "active",
"customer": "cus_...",
"cancel_at_period_end": true,
"items": {
"data": [
{
"price": { "id": "price_...", "recurring": { "interval": "month" } },
"current_period_end": 1769904000
}
]
}
},
"previous_attributes": { "cancel_at_period_end": false }
}
}Testing these events
Forward events to your machine with stripe listen --forward-to localhost:3000/webhook and fire one with stripe trigger checkout.session.completed. For real flows, pay with a Stripe test card, and reach renewals and trial ends with a test clock. If verification fails, paste the request into the signature checker.
These are the events Refgrow listens to for you.
Refgrow reads checkout.session.completed, invoice.paid, subscription and refund events from your Stripe account, credits the affiliate behind each payment, and reverses commissions on refunds. No webhook code on your side.
- Webhook endpoint created for you on connect
- Recurring commissions on every paid renewal
- Refunds reverse the commission automatically
- Dry-run test mode before launch