Issue refunds programmatically with stripe.refunds.create(), passing the payment_intent or charge ID, optional amount for partial refunds, and a reason code (requested_by_customer, duplicate, or fraudulent). Handle webhook events for refund.updated and charge.refunded to track outcomes. This is essential for building self-service refund portals and automated return workflows.
Programmatic Refund Management with the Stripe API
For businesses handling more than a few refunds per day, manual Dashboard refunds do not scale. The Stripe Refunds API lets you automate refund workflows — triggered by customer requests, return authorizations, or fraud detection. You can issue full or partial refunds, attach metadata for internal tracking, specify reason codes for reporting, and monitor results through webhooks. This guide covers the complete API-driven refund lifecycle.
Prerequisites
- A Stripe account with successful test payments to refund
- Node.js 18 or newer installed
- The stripe npm package installed (npm install stripe)
- Your Stripe secret key (sk_test_...) from Dashboard → Developers → API keys
Step-by-step guide
Issue a basic full refund
Issue a basic full refund
Create a refund by passing the payment_intent ID. Without an amount, Stripe refunds the full charge.
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);23const refund = await stripe.refunds.create({4 payment_intent: 'pi_ABC123',5});67console.log('Refund:', refund.id, refund.status);Expected result: A full refund is created with status 'succeeded' for card payments.
Issue a partial refund with reason and metadata
Issue a partial refund with reason and metadata
Pass an amount (in cents), a reason code, and metadata to track the refund in your system.
1const refund = await stripe.refunds.create({2 payment_intent: 'pi_ABC123',3 amount: 2500, // Refund $25.004 reason: 'requested_by_customer',5 metadata: {6 order_id: 'order_98765',7 return_id: 'ret_111',8 refund_type: 'partial_return',9 },10});Expected result: A $25.00 partial refund is created with reason code and metadata attached.
Handle refund errors
Handle refund errors
Catch specific error codes when refund creation fails. Common errors include already-refunded charges and amounts exceeding the refundable balance.
1try {2 const refund = await stripe.refunds.create({3 payment_intent: 'pi_ABC123',4 amount: 50000, // might exceed original amount5 });6} catch (err) {7 switch (err.code) {8 case 'charge_already_refunded':9 console.log('This payment has already been fully refunded.');10 break;11 case 'amount_too_large':12 console.log('Refund amount exceeds the refundable balance.');13 break;14 case 'charge_expired_for_refund':15 console.log('This charge is too old to refund via the API.');16 break;17 default:18 console.log('Refund error:', err.message);19 }20}Expected result: Errors are caught and handled with appropriate messages.
Track refunds via webhooks
Track refunds via webhooks
Set up webhook listeners for refund events. The charge.refunded event fires when a refund succeeds, and refund.failed fires if a refund fails (common with bank transfers).
1app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {2 const sig = req.headers['stripe-signature'];3 let event;45 try {6 event = stripe.webhooks.constructEvent(7 req.body,8 sig,9 process.env.STRIPE_WEBHOOK_SECRET10 );11 } catch (err) {12 return res.status(400).send(`Webhook Error: ${err.message}`);13 }1415 switch (event.type) {16 case 'charge.refunded':17 const charge = event.data.object;18 console.log(`Charge ${charge.id} refunded. Amount refunded: ${charge.amount_refunded / 100}`);19 break;20 case 'refund.failed':21 const refund = event.data.object;22 console.log(`Refund ${refund.id} failed: ${refund.failure_reason}`);23 break;24 }2526 res.json({ received: true });27});Expected result: Your server logs refund successes and failures from webhook events.
List refunds for a payment
List refunds for a payment
Retrieve all refunds associated with a specific charge to see the refund history.
1const refunds = await stripe.refunds.list({2 payment_intent: 'pi_ABC123',3 limit: 10,4});56for (const refund of refunds.data) {7 console.log(refund.id, refund.amount / 100, refund.status, refund.reason);8}Expected result: A list of all refunds for the payment, with amounts, statuses, and reasons.
Complete working example
1const express = require('express');2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);34const app = express();56// Webhook handler (must be before express.json middleware)7app.post('/webhook',8 express.raw({ type: 'application/json' }),9 (req, res) => {10 const sig = req.headers['stripe-signature'];11 let event;1213 try {14 event = stripe.webhooks.constructEvent(15 req.body,16 sig,17 process.env.STRIPE_WEBHOOK_SECRET18 );19 } catch (err) {20 return res.status(400).send(`Webhook Error: ${err.message}`);21 }2223 switch (event.type) {24 case 'charge.refunded':25 console.log('Refund confirmed:', event.data.object.id);26 break;27 case 'refund.failed':28 console.log('Refund failed:', event.data.object.failure_reason);29 break;30 }3132 res.json({ received: true });33 }34);3536app.use(express.json());3738// Create refund endpoint39app.post('/api/refunds', async (req, res) => {40 try {41 const { paymentIntentId, amount, reason, metadata } = req.body;4243 const params = {44 payment_intent: paymentIntentId,45 reason: reason || 'requested_by_customer',46 metadata: metadata || {},47 };48 if (amount) params.amount = amount;4950 const refund = await stripe.refunds.create(params);5152 res.json({53 id: refund.id,54 amount: refund.amount / 100,55 status: refund.status,56 reason: refund.reason,57 });58 } catch (err) {59 const status = err.code === 'charge_already_refunded' ? 400 : 500;60 res.status(status).json({ error: err.message, code: err.code });61 }62});6364// List refunds for a payment65app.get('/api/refunds', async (req, res) => {66 try {67 const { payment_intent } = req.query;68 const refunds = await stripe.refunds.list({ payment_intent, limit: 20 });69 res.json(refunds.data.map((r) => ({70 id: r.id,71 amount: r.amount / 100,72 status: r.status,73 reason: r.reason,74 created: new Date(r.created * 1000).toISOString(),75 })));76 } catch (err) {77 res.status(500).json({ error: err.message });78 }79});8081const PORT = process.env.PORT || 3000;82app.listen(PORT, () => console.log(`Server on port ${PORT}`));Common mistakes when programmaticallying issue refunds with Stripe API
Why it's a problem: Not using express.raw() for the webhook endpoint
How to avoid: stripe.webhooks.constructEvent requires the raw request body. If you use express.json() globally, exclude the webhook route or place it before the JSON middleware.
Why it's a problem: Ignoring refund.failed webhook events
How to avoid: Bank transfer refunds can fail. Always listen for refund.failed and notify your team or customer when a refund does not complete.
Why it's a problem: Not using metadata to link refunds to internal records
How to avoid: Add your order ID, return ID, and reason to refund metadata. This makes reconciliation and customer service lookup much easier.
Why it's a problem: Issuing refunds without validating the original payment status
How to avoid: Verify the PaymentIntent status is 'succeeded' before attempting a refund. You cannot refund a payment that is still processing or has already been refunded.
Best practices
- Always validate that the PaymentIntent is in 'succeeded' status before issuing a refund
- Use reason codes consistently — 'fraudulent' trains Stripe Radar to detect similar fraud patterns
- Attach metadata with your internal order and return IDs for easy reconciliation
- Place the webhook route before express.json() middleware to preserve the raw body
- Implement idempotency keys for refund creation to prevent duplicate refunds on retries
- Monitor refund rates in Stripe Dashboard — high refund rates can trigger account review
- Test the full refund lifecycle in test mode: create payment, issue refund, verify webhook
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a Node.js Express API for managing Stripe refunds. Include: POST /refunds to create a refund with amount, reason, and metadata; GET /refunds?payment_intent=... to list refunds; and a POST /webhook endpoint using stripe.webhooks.constructEvent with raw body to handle charge.refunded and refund.failed events.
Build a refund management system for my app. Create endpoints to issue full/partial refunds with reason codes and metadata, list all refunds for a payment, and handle refund webhook events. Validate payment status before refunding.
Frequently asked questions
Can I refund a charge using the charge ID instead of the PaymentIntent ID?
Yes. Pass charge: 'ch_ABC123' instead of payment_intent: 'pi_ABC123'. Both work. The PaymentIntent approach is recommended for newer integrations.
What are the valid reason codes?
Stripe accepts three reason codes: 'requested_by_customer', 'duplicate', and 'fraudulent'. These are used for reporting and analytics. Using 'fraudulent' also feeds Stripe's fraud detection system.
Can I issue a refund larger than the original charge?
No. Stripe rejects refunds that exceed the original charge amount (minus any previous refunds). You will receive an 'amount_too_large' error.
How do I handle idempotency for refunds?
Pass an idempotency_key option: stripe.refunds.create(params, { idempotencyKey: 'unique-key' }). This prevents duplicate refunds if your server retries the request.
How long do refunds take to appear on the customer's statement?
Stripe processes the refund within seconds, but banks take 5-10 business days to post the credit. Some banks may take up to 20 business days.
What if I need to build a self-service refund portal for customers?
For a customer-facing refund portal with policy enforcement, approval workflows, and real-time status tracking, the RapidDev team can help design and implement a complete refund management system.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation