Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 025 / 100

Refunds done right: a practical Payments & Stripe guide

Return money safely and traceably.

Expona Admin10 min read

The refund problem nobody talks about

Refunds look simple on the surface: customer asks for their money back, you process it, done. In reality, refunds are where payment systems break down—double charges happen, audit trails vanish, and customer support gets flooded with "where's my money?" tickets. We've built enough payment systems to know that refunds done right: a practical Payments & Stripe guide means treating them with the same rigor as the initial charge, complete with idempotency, webhooks, and clear business rules.

Full and partial refunds — Refund by amount against a PaymentIntent

In Stripe, a refund is a separate object linked to a charge or payment intent. You can refund the full amount immediately, or split it into multiple partial refunds over time. The key is understanding that you're not reversing the original charge—you're creating a new refund record that Stripe then settles back to the customer's payment method.

Here's a real-world scenario: a customer orders a $150 item, but the warehouse finds it's damaged. You issue a full refund. Two weeks later, they decide to keep the original and order a replacement, so you process a $30 restocking fee. Now you need a partial refund of $120. Each of these is a separate refund object in Stripe.

To refund a PaymentIntent, you'll use the Refunds API. Here's how:

typescript
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Full refund
async function refundPaymentIntent(paymentIntentId: string) {
  const refund = await stripe.refunds.create({
    payment_intent: paymentIntentId,
  });
  
  return refund;
}

// Partial refund (amount in cents)
async function refundPartial(paymentIntentId: string, amountCents: number) {
  const refund = await stripe.refunds.create({
    payment_intent: paymentIntentId,
    amount: amountCents, // e.g., 12000 for $120
  });
  
  return refund;
}

The refund object gives you back a status (succeeded, failed, or pending), a timestamp, and a reason code if needed. Store the refund ID in your database immediately—it's your proof that the refund was initiated.

One important detail: you can only refund up to the original charge amount. If a customer has been partially refunded already, Stripe tracks the total refunded and prevents over-refunding. That said, you should enforce this in your own business logic, not rely on Stripe to catch it. More on that in the business rules section.

Idempotency — Use keys so retries do not double-refund

Network failures happen. Your refund request succeeds on Stripe's side, but your connection drops before you get the response. Do you retry? If you do without protection, you've now created two refunds—the customer gets double the money back, your balance goes negative, and reconciliation becomes a nightmare.

Stripe provides idempotency keys to prevent this. Think of them as a unique fingerprint for each refund request. If you send the same key twice, Stripe returns the same refund object without creating a duplicate.

typescript
import crypto from 'crypto';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function refundSafely(
  paymentIntentId: string,
  amountCents: number,
  orderId: string
) {
  // Generate a deterministic idempotency key
  // Use order ID + action to ensure uniqueness
  const idempotencyKey = `refund-${orderId}-${amountCents}`;
  
  try {
    const refund = await stripe.refunds.create(
      {
        payment_intent: paymentIntentId,
        amount: amountCents,
      },
      {
        idempotencyKey, // Stripe deduplicates on this key
      }
    );
    
    return { success: true, refund };
  } catch (error) {
    // Safe to retry with same key
    console.error('Refund failed:', error);
    throw error;
  }
}

// Usage
const result = await refundSafely(pi_1234567, 12000, 'order-abc123');
// If this request times out, retry with same order ID and amount
// Stripe will return the existing refund, no duplicate

The idempotency key should be deterministic based on the refund reason and amount. This way, if your retry logic kicks in after a timeout, you'll send the same key and Stripe will give you the refund that was already created. Never use random keys for the same refund—you'll defeat the purpose.

In production, Stripe stores idempotency keys for 24 hours. After that, a new key is treated as a new request. This is fine for most use cases, but be aware of it if you're building retry logic with very long delays.

Webhooks — charge.refunded updates your records

Creating a refund in Stripe is one thing. Updating your own database, notifying the customer, and triggering fulfillment adjustments is another. Webhooks bridge that gap.

When a refund succeeds, Stripe fires a charge.refunded webhook. Your backend should listen for it and update your records accordingly. This is especially important for partial refunds—you need to track which portion of the order has been refunded.

typescript
import express from 'express';
import Stripe from 'stripe';

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Raw body needed for signature verification
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature'] as string;
    let event: Stripe.Event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error('Webhook signature verification failed');
      return res.status(400).send('Webhook error');
    }

    if (event.type === 'charge.refunded') {
      const charge = event.data.object as Stripe.Charge;
      
      // charge.refunded contains the refunded amount
      const refundedAmount = charge.amount_refunded;
      const chargeId = charge.id;

      // Find the order by charge ID
      const order = await db.orders.findOne({ stripeChargeId: chargeId });
      
      if (order) {
        // Update refund status
        await db.orders.updateOne(
          { _id: order._id },
          {
            $set: {
              refundedAmount,
              refundStatus: refundedAmount === order.amount ? 'full' : 'partial',
              lastRefundedAt: new Date(),
            },
          }
        );

        // Notify customer
        await sendRefundNotification(order.customerId, refundedAmount);

        // If fully refunded, mark for inventory re-stocking
        if (refundedAmount === order.amount) {
          await restockInventory(order.items);
        }
      }
    }

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

A few important points: first, always verify the webhook signature. Anyone can POST to your endpoint; Stripe's signature proves the event came from Stripe. Second, webhooks can arrive out of order or be retried multiple times. Your code should be idempotent—if you process the same webhook twice, you should get the same result (no duplicate notifications, no double inventory adjustments). Third, store the webhook event ID so you can deduplicate on your end if needed.

Business rules — Who can refund, and up to what amount

Technically, you can refund any amount up to the charge total. Legally, operationally, and financially, you probably shouldn't. This is where business rules come in.

Common rules we've seen in production:

  • Support agents can refund up to $50 without approval; anything above needs manager sign-off.
  • Refund window: refunds allowed within 60 days of purchase; after that, manual review required.
  • Damage/defect: full refund no questions asked. Buyer's remorse: 20% restocking fee.
  • Fraud suspected: refund denied until investigation complete.

Encode these rules in your backend before you ever call Stripe. Here's a practical example:

typescript
interface RefundRequest {
  orderId: string;
  amountCents: number;
  reason: 'defect' | 'remorse' | 'fraud' | 'other';
  initiatedBy: 'customer' | 'support' | 'system';
  userId?: string; // Support agent ID if applicable
}

interface RefundPolicy {
  maxAmountCents: number;
  requiresApproval: boolean;
  comment: string;
}

async function validateRefund(req: RefundRequest, order: Order): Promise {
  // Check refund window
  const daysSincePurchase = Math.floor(
    (Date.now() - order.createdAt.getTime()) / (1000 * 60 * 60 * 24)
  );

  if (daysSincePurchase > 60) {
    return {
      maxAmountCents: 0,
      requiresApproval: true,
      comment: 'Outside 60-day refund window',
    };
  }

  // Check amount against reason
  if (req.reason === 'defect') {
    return {
      maxAmountCents: order.totalCents,
      requiresApproval: false,
      comment: 'Full refund for defect',
    };
  }

  if (req.reason === 'remorse') {
    const restockingFee = Math.floor(order.totalCents * 0.2);
    return {
      maxAmountCents: order.totalCents - restockingFee,
      requiresApproval: req.amountCents > 5000, // Over $50
      comment: '20% restocking fee applied',
    };
  }

  if (req.reason === 'fraud') {
    return {
      maxAmountCents: 0,
      requiresApproval: true,
      comment: 'Fraud flag requires investigation',
    };
  }

  // Default: require approval for anything else
  return {
    maxAmountCents: req.amountCents,
    requiresApproval: true,
    comment: 'Review required',
  };
}

async function processRefund(req: RefundRequest) {
  const order = await db.orders.findById(req.orderId);
  const policy = await validateRefund(req, order);

  if (req.amountCents > policy.maxAmountCents) {
    throw new Error(`Refund exceeds policy limit: ${policy.comment}`);
  }

  if (policy.requiresApproval) {
    // Create approval task, don't refund yet
    await db.approvals.create({
      orderId: req.orderId,
      amountCents: req.amountCents,
      reason: req.reason,
      status: 'pending',
    });
    return { status: 'pending_approval', message: policy.comment };
  }

  // Auto-approved, process refund
  const refund = await refundSafely(
    order.stripePaymentIntentId,
    req.amountCents,
    req.orderId
  );

  await db.orders.updateOne(
    { _id: req.orderId },
    { $set: { refundStatus: 'processing', refundId: refund.id } }
  );

  return { status: 'refunding', refundId: refund.id };
}

This approach keeps your business logic separate from the Stripe integration. If your policy changes—say you want to extend the refund window to 90 days—you update one function, not your entire codebase.

Common mistakes

  • No idempotency keys: Retry logic without idempotency keys creates duplicate refunds. Always generate them deterministically based on order ID and amount.
  • Ignoring webhook retries: Webhooks get retried for 3 days if your endpoint doesn't return 200. If your code isn't idempotent, you'll process the same refund multiple times.
  • Not tracking refund status: Creating a refund in Stripe doesn't mean it succeeded on the customer's card. Pending refunds can fail. Always check the refund object status and update your database accordingly.
  • Trusting Stripe's max-refund logic alone: Yes, Stripe prevents over-refunding. But you should still validate refund amounts and reasons before calling Stripe. Your customers need to understand why they can't refund $1M.
  • Missing refund reason tracking: Store why the refund happened. You'll need this for disputes, chargebacks, and financial reconciliation.

Key takeaways

  • Refunds are separate Stripe objects; create them with the Refunds API and store the refund ID in your database.
  • Always use idempotency keys to prevent duplicate refunds. Generate them deterministically from order ID and amount.
  • Listen for charge.refunded webhooks and update your records when they arrive. Make your handlers idempotent.
  • Encode your refund policy in code before calling Stripe. Validate reason, amount, timing, and user permissions.
  • Track refund status, reason, and timestamp. You'll need this for audits, disputes, and customer support.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles