# How to programmatically issue refunds with Stripe API

- Tool: Stripe
- Difficulty: Intermediate
- Time required: 20 minutes
- Compatibility: Stripe API v2024-12+, Node.js 18+, any backend framework
- Last updated: March 2026

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

## Before you start

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

```
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const refund = await stripe.refunds.create({
  payment_intent: 'pi_ABC123',
});

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

```
const refund = await stripe.refunds.create({
  payment_intent: 'pi_ABC123',
  amount: 2500, // Refund $25.00
  reason: 'requested_by_customer',
  metadata: {
    order_id: 'order_98765',
    return_id: 'ret_111',
    refund_type: 'partial_return',
  },
});
```

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

```
try {
  const refund = await stripe.refunds.create({
    payment_intent: 'pi_ABC123',
    amount: 50000, // might exceed original amount
  });
} catch (err) {
  switch (err.code) {
    case 'charge_already_refunded':
      console.log('This payment has already been fully refunded.');
      break;
    case 'amount_too_large':
      console.log('Refund amount exceeds the refundable balance.');
      break;
    case 'charge_expired_for_refund':
      console.log('This charge is too old to refund via the API.');
      break;
    default:
      console.log('Refund error:', err.message);
  }
}
```

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

```
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  switch (event.type) {
    case 'charge.refunded':
      const charge = event.data.object;
      console.log(`Charge ${charge.id} refunded. Amount refunded: ${charge.amount_refunded / 100}`);
      break;
    case 'refund.failed':
      const refund = event.data.object;
      console.log(`Refund ${refund.id} failed: ${refund.failure_reason}`);
      break;
  }

  res.json({ received: true });
});
```

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

```
const refunds = await stripe.refunds.list({
  payment_intent: 'pi_ABC123',
  limit: 10,
});

for (const refund of refunds.data) {
  console.log(refund.id, refund.amount / 100, refund.status, refund.reason);
}
```

**Expected result:** A list of all refunds for the payment, with amounts, statuses, and reasons.

## Complete code example

File: `refund-api.js`

```javascript
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();

// Webhook handler (must be before express.json middleware)
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    switch (event.type) {
      case 'charge.refunded':
        console.log('Refund confirmed:', event.data.object.id);
        break;
      case 'refund.failed':
        console.log('Refund failed:', event.data.object.failure_reason);
        break;
    }

    res.json({ received: true });
  }
);

app.use(express.json());

// Create refund endpoint
app.post('/api/refunds', async (req, res) => {
  try {
    const { paymentIntentId, amount, reason, metadata } = req.body;

    const params = {
      payment_intent: paymentIntentId,
      reason: reason || 'requested_by_customer',
      metadata: metadata || {},
    };
    if (amount) params.amount = amount;

    const refund = await stripe.refunds.create(params);

    res.json({
      id: refund.id,
      amount: refund.amount / 100,
      status: refund.status,
      reason: refund.reason,
    });
  } catch (err) {
    const status = err.code === 'charge_already_refunded' ? 400 : 500;
    res.status(status).json({ error: err.message, code: err.code });
  }
});

// List refunds for a payment
app.get('/api/refunds', async (req, res) => {
  try {
    const { payment_intent } = req.query;
    const refunds = await stripe.refunds.list({ payment_intent, limit: 20 });
    res.json(refunds.data.map((r) => ({
      id: r.id,
      amount: r.amount / 100,
      status: r.status,
      reason: r.reason,
      created: new Date(r.created * 1000).toISOString(),
    })));
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
```

## Common mistakes

- **Not using express.raw() for the webhook endpoint** — undefined Fix: 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.
- **Ignoring refund.failed webhook events** — undefined Fix: Bank transfer refunds can fail. Always listen for refund.failed and notify your team or customer when a refund does not complete.
- **Not using metadata to link refunds to internal records** — undefined Fix: Add your order ID, return ID, and reason to refund metadata. This makes reconciliation and customer service lookup much easier.
- **Issuing refunds without validating the original payment status** — undefined Fix: 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

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

---

Source: https://www.rapidevelopers.com/stripe-guide/how-to-programmatically-issue-refunds-with-stripe-api
© RapidDev — https://www.rapidevelopers.com/stripe-guide/how-to-programmatically-issue-refunds-with-stripe-api
