Skip to main content
RapidDev - Software Development Agency
stripe-guide

How to programmatically issue refunds with Stripe API

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.

What you'll learn

  • How to issue full, partial, and metadata-tagged refunds via the Stripe API
  • How to use reason codes for analytics and reporting
  • How to handle refund failures and edge cases
  • How to track refund status via webhooks
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate6 min read20 minutesStripe API v2024-12+, Node.js 18+, any backend frameworkMarch 2026RapidDev Engineering Team
TL;DR

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

1

Issue a basic full refund

Create a refund by passing the payment_intent ID. Without an amount, Stripe refunds the full charge.

typescript
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
2
3const refund = await stripe.refunds.create({
4 payment_intent: 'pi_ABC123',
5});
6
7console.log('Refund:', refund.id, refund.status);

Expected result: A full refund is created with status 'succeeded' for card payments.

2

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.

typescript
1const refund = await stripe.refunds.create({
2 payment_intent: 'pi_ABC123',
3 amount: 2500, // Refund $25.00
4 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.

3

Handle refund errors

Catch specific error codes when refund creation fails. Common errors include already-refunded charges and amounts exceeding the refundable balance.

typescript
1try {
2 const refund = await stripe.refunds.create({
3 payment_intent: 'pi_ABC123',
4 amount: 50000, // might exceed original amount
5 });
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.

4

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).

typescript
1app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
2 const sig = req.headers['stripe-signature'];
3 let event;
4
5 try {
6 event = stripe.webhooks.constructEvent(
7 req.body,
8 sig,
9 process.env.STRIPE_WEBHOOK_SECRET
10 );
11 } catch (err) {
12 return res.status(400).send(`Webhook Error: ${err.message}`);
13 }
14
15 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 }
25
26 res.json({ received: true });
27});

Expected result: Your server logs refund successes and failures from webhook events.

5

List refunds for a payment

Retrieve all refunds associated with a specific charge to see the refund history.

typescript
1const refunds = await stripe.refunds.list({
2 payment_intent: 'pi_ABC123',
3 limit: 10,
4});
5
6for (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

refund-api.js
1const express = require('express');
2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
3
4const app = express();
5
6// 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;
12
13 try {
14 event = stripe.webhooks.constructEvent(
15 req.body,
16 sig,
17 process.env.STRIPE_WEBHOOK_SECRET
18 );
19 } catch (err) {
20 return res.status(400).send(`Webhook Error: ${err.message}`);
21 }
22
23 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 }
31
32 res.json({ received: true });
33 }
34);
35
36app.use(express.json());
37
38// Create refund endpoint
39app.post('/api/refunds', async (req, res) => {
40 try {
41 const { paymentIntentId, amount, reason, metadata } = req.body;
42
43 const params = {
44 payment_intent: paymentIntentId,
45 reason: reason || 'requested_by_customer',
46 metadata: metadata || {},
47 };
48 if (amount) params.amount = amount;
49
50 const refund = await stripe.refunds.create(params);
51
52 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});
63
64// List refunds for a payment
65app.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});
80
81const 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.

ChatGPT Prompt

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.

Stripe Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.