RK
RefundKit
guidesdkbest-practices

The Developer's Guide to Programmatic Refunds

RefundKit Team·

Introduction

Processing refunds sounds simple until you actually build it. You call an API, money goes back to the customer, done. Except it is not done, because refunds involve distributed systems, asynchronous state machines, financial regulations, and edge cases that can cost you real money when they go wrong.

A double refund because your retry logic did not handle idempotency correctly. A stuck refund because you did not process a webhook. A customer claiming they never received a refund because your status page showed stale data. A compliance audit you cannot pass because your refund records are incomplete.

This guide covers the full lifecycle of programmatic refunds -- from the moment a refund is initiated to the moment the customer sees the money back in their account. It is written for developers who are building refund flows into their applications, whether through a unified refund layer like RefundKit or directly against payment processor APIs.

The Refund Lifecycle

A refund is not a single event. It is a state machine with multiple transitions, some of which happen synchronously (within your API call) and others asynchronously (minutes, hours, or days later via webhooks).

States

A typical refund passes through these states:

created -> processing -> succeeded
                     \-> failed
                     \-> requires_approval -> approved -> processing -> succeeded
                                          \-> rejected
  • created: The refund request has been received and validated, but not yet sent to the payment processor.
  • processing: The refund has been submitted to the payment processor and is being executed.
  • succeeded: The processor has confirmed the refund. The money is on its way back (though the customer might not see it for days).
  • failed: The processor rejected the refund. Common reasons include insufficient balance, invalid charge, or the charge being too old to refund.
  • requires_approval: The refund exceeded a threshold and needs manual approval before processing.
  • approved / rejected: The outcome of the manual approval step.

Understanding these states matters because your application needs to handle each one. A refund in processing is not the same as a refund that succeeded -- if you tell the customer "your refund has been processed" when it is still in processing, you might have to retract that statement if it fails.

Initiating a Refund

Here is what a basic refund initiation looks like:

import RefundKit from '@refundkit/sdk';

const rk = new RefundKit({ apiKey: process.env.REFUNDKIT_API_KEY! });

async function initiateRefund(
  transactionId: string,
  amount?: number,
  reason?: string
) {
  try {
    const refund = await rk.refunds.create({
      transactionId,
      amount,      // Omit for full refund
      reason,      // 'customer_request' | 'duplicate' | 'fraudulent' | 'product_issue'
      metadata: {
        initiatedBy: 'support-dashboard',
        ticketId: 'ticket_abc123'
      }
    });

    console.log(`Refund ${refund.id} created with status: ${refund.status}`);
    return refund;
  } catch (error) {
    if (error instanceof RefundKit.PolicyViolationError) {
      // The refund was rejected by your policy rules
      console.error(`Policy violation: ${error.code} - ${error.message}`);
    } else if (error instanceof RefundKit.ProcessorError) {
      // The payment processor rejected the refund
      console.error(`Processor error: ${error.processorCode} - ${error.message}`);
    } else {
      // Network error, timeout, or unexpected failure
      console.error('Unexpected error:', error);
    }
    throw error;
  }
}

Notice the explicit error handling with typed errors. This is not optional. Refund failures need different treatment depending on the cause. A policy violation means the refund should not happen (do not retry). A processor error might be transient (retry might help) or permanent (do not retry). A network error is almost certainly transient (retry after backoff).

Idempotency

Idempotency is the single most important concept in refund processing. An idempotent operation produces the same result whether you call it once or ten times. For refunds, this means: if you accidentally submit the same refund request twice, only one refund should be created.

Why Idempotency Matters

Consider this sequence of events:

  1. Your server sends a refund request to the payment processor.
  2. The processor receives the request and starts processing the refund.
  3. The network connection drops before your server receives the response.
  4. Your server does not know if the refund was created or not.
  5. Your retry logic sends the same request again.
  6. Without idempotency, the processor creates a second refund.

The customer gets refunded twice. You lose money. This is not a theoretical scenario -- it happens in production systems regularly, especially under load or during processor outages.

Implementing Idempotency

There are two approaches to idempotency: client-side idempotency keys and server-side deduplication.

Client-side idempotency keys are unique identifiers you generate for each refund request and send along with the request. If the server receives two requests with the same idempotency key, it returns the result of the first request without processing a second refund.

import { randomUUID } from 'crypto';

async function safeRefund(transactionId: string, amount: number) {
  // Generate a deterministic idempotency key from the refund parameters
  // Using a hash ensures the same logical refund always gets the same key
  const idempotencyKey = generateDeterministicKey(transactionId, amount);

  const refund = await rk.refunds.create(
    {
      transactionId,
      amount,
      reason: 'customer_request'
    },
    {
      idempotencyKey
    }
  );

  return refund;
}

function generateDeterministicKey(
  transactionId: string,
  amount: number
): string {
  const crypto = require('crypto');
  return crypto
    .createHash('sha256')
    .update(`refund:${transactionId}:${amount}:${Date.now().toString().slice(0, -4)}`)
    .digest('hex')
    .slice(0, 32);
}

A subtlety with deterministic keys: the key should be deterministic enough that retries of the same logical operation produce the same key, but unique enough that two legitimately different refunds for the same transaction do not collide. Including a truncated timestamp (grouped by 10-second windows, for example) handles the case where a customer legitimately requests a second partial refund later.

Server-side deduplication is handled by the refund infrastructure. RefundKit, for example, automatically deduplicates refund requests that match on transaction ID, amount, and timing. If you submit two identical refund requests within a short window, the second one returns the result of the first.

// RefundKit handles deduplication automatically
// These two calls (if made in quick succession) will only create one refund
const refund1 = await rk.refunds.create({
  transactionId: 'txn_abc123',
  amount: 2500,
  reason: 'customer_request'
});

const refund2 = await rk.refunds.create({
  transactionId: 'txn_abc123',
  amount: 2500,
  reason: 'customer_request'
});

// refund1.id === refund2.id (same refund, not a duplicate)

In practice, you should use both approaches. Client-side idempotency keys give you explicit control, and server-side deduplication provides a safety net.

Webhook Handling

Refund status changes happen asynchronously. You submit a refund, get back a processing status, and later the processor confirms it succeeded (or failed). The mechanism for receiving these asynchronous updates is webhooks.

Setting Up Webhook Endpoints

A webhook endpoint is an HTTP endpoint in your application that receives POST requests from the refund system when events occur. Here is a production-quality webhook handler:

import express from 'express';
import RefundKit from '@refundkit/sdk';

const app = express();
const rk = new RefundKit({ apiKey: process.env.REFUNDKIT_API_KEY! });

// IMPORTANT: Use raw body for signature verification
app.post(
  '/webhooks/refundkit',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // Step 1: Verify the webhook signature
    const signature = req.headers['refundkit-signature'] as string;
    let event;

    try {
      event = rk.webhooks.verify(
        req.body,
        signature,
        process.env.REFUNDKIT_WEBHOOK_SECRET!
      );
    } catch (err) {
      console.error('Webhook verification failed:', err);
      return res.status(400).json({ error: 'Invalid signature' });
    }

    // Step 2: Process the event idempotently
    const processed = await hasEventBeenProcessed(event.id);
    if (processed) {
      // Already handled this event -- return 200 so it is not retried
      return res.status(200).json({ received: true, duplicate: true });
    }

    // Step 3: Handle the event
    try {
      await handleRefundEvent(event);
      await markEventAsProcessed(event.id);
    } catch (err) {
      console.error(`Failed to handle event ${event.id}:`, err);
      // Return 500 so the webhook is retried
      return res.status(500).json({ error: 'Processing failed' });
    }

    // Step 4: Acknowledge receipt
    res.status(200).json({ received: true });
  }
);

async function handleRefundEvent(event: RefundKitEvent) {
  switch (event.type) {
    case 'refund.succeeded': {
      const refund = event.data;
      // Update your order management system
      await updateOrderRefundStatus(refund.metadata.orderId, 'refunded');
      // Send confirmation to customer
      await sendRefundConfirmationEmail(refund);
      // Update accounting records
      await createAccountingEntry(refund);
      break;
    }

    case 'refund.failed': {
      const refund = event.data;
      // Alert the support team
      await notifySupport({
        type: 'refund_failed',
        refundId: refund.id,
        reason: refund.failureReason,
        transactionId: refund.transactionId
      });
      // Update order status
      await updateOrderRefundStatus(refund.metadata.orderId, 'refund_failed');
      break;
    }

    case 'refund.requires_approval': {
      const refund = event.data;
      // Send approval request to finance team
      await createApprovalRequest({
        refundId: refund.id,
        amount: refund.amount,
        currency: refund.currency,
        reason: refund.reason,
        requestedBy: refund.metadata.initiatedBy
      });
      break;
    }
  }
}

Critical Webhook Patterns

Several patterns are essential for reliable webhook processing:

Signature verification is mandatory. Without it, anyone who discovers your webhook URL can send fake events. The signature proves the webhook came from RefundKit and has not been tampered with. Never skip this step, even in development.

Idempotent event processing. Webhooks can be delivered more than once. If your handler creates an accounting entry on refund.succeeded, it must not create a duplicate entry if the same event is delivered twice. Track processed event IDs and skip duplicates.

Return quickly, process asynchronously. Webhook endpoints have timeout limits (typically 5-30 seconds). If your handler needs to do slow operations (sending emails, updating external systems), acknowledge the webhook immediately and process the event asynchronously through a job queue:

app.post(
  '/webhooks/refundkit',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const event = rk.webhooks.verify(
      req.body,
      req.headers['refundkit-signature'] as string,
      process.env.REFUNDKIT_WEBHOOK_SECRET!
    );

    // Enqueue for async processing and respond immediately
    await jobQueue.enqueue('process-refund-webhook', { event });

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

Handle webhook retries gracefully. If your endpoint returns a 5xx status code, the webhook will be retried with exponential backoff. Design your handler so that retries are safe (back to idempotency) and helpful (they recover from transient failures).

Testing with Test Keys

Testing refund flows against live payment processors is expensive and slow. Test keys let you simulate the full refund lifecycle without moving real money.

Setting Up Test Mode

RefundKit test keys (prefixed with rk_test_) connect to sandboxed versions of payment processors. Transactions, refunds, and webhooks all work the same way as production, but no real charges or refunds are processed.

// Test environment setup
const rk = new RefundKit({
  apiKey: 'rk_test_your_test_key',
});

// Create a test transaction to refund
const transaction = await rk.transactions.create({
  amount: 9999,    // $99.99
  currency: 'usd',
  processor: 'stripe',
  customerId: 'cust_test_001',
  metadata: { orderId: 'test_order_001' }
});

Simulating Edge Cases

Test mode supports special values that trigger specific behaviors, useful for testing error handling:

// Simulate a refund that fails
const failingRefund = await rk.refunds.create({
  transactionId: 'txn_test_force_fail',  // Special test transaction ID
  amount: 5000,
  reason: 'customer_request'
});
// Returns a refund with status: 'failed'

// Simulate a refund that takes time to process
const slowRefund = await rk.refunds.create({
  transactionId: 'txn_test_slow_process',
  amount: 5000,
  reason: 'customer_request'
});
// Returns status: 'processing', webhook fires after 30 seconds

// Simulate a refund that requires approval
const approvalRefund = await rk.refunds.create({
  transactionId: 'txn_test_needs_approval',
  amount: 50000,  // Large amount triggers approval
  reason: 'customer_request'
});
// Returns status: 'requires_approval'

Writing Automated Tests

Your test suite should cover the happy path, error cases, and edge cases:

import { describe, it, expect, beforeAll } from 'vitest';
import RefundKit from '@refundkit/sdk';

const rk = new RefundKit({ apiKey: process.env.REFUNDKIT_TEST_KEY! });

describe('Refund Flow', () => {
  let testTransaction: Transaction;

  beforeAll(async () => {
    testTransaction = await rk.transactions.create({
      amount: 10000,
      currency: 'usd',
      customerId: 'cust_test_integration',
      metadata: { orderId: 'test_order_integration' }
    });
  });

  it('should process a full refund', async () => {
    const refund = await rk.refunds.create({
      transactionId: testTransaction.id,
      reason: 'customer_request'
    });

    expect(refund.amount).toBe(10000);
    expect(refund.currency).toBe('usd');
    expect(refund.status).toMatch(/succeeded|processing/);
    expect(refund.transactionId).toBe(testTransaction.id);
  });

  it('should process a partial refund', async () => {
    const newTransaction = await rk.transactions.create({
      amount: 10000,
      currency: 'usd',
      customerId: 'cust_test_partial',
    });

    const refund = await rk.refunds.create({
      transactionId: newTransaction.id,
      amount: 5000,  // Half the original amount
      reason: 'product_issue'
    });

    expect(refund.amount).toBe(5000);
    expect(refund.status).toMatch(/succeeded|processing/);
  });

  it('should reject refund exceeding transaction amount', async () => {
    const newTransaction = await rk.transactions.create({
      amount: 5000,
      currency: 'usd',
      customerId: 'cust_test_over',
    });

    await expect(
      rk.refunds.create({
        transactionId: newTransaction.id,
        amount: 10000,  // Double the transaction amount
        reason: 'customer_request'
      })
    ).rejects.toThrow(RefundKit.PolicyViolationError);
  });

  it('should reject refund for non-existent transaction', async () => {
    await expect(
      rk.refunds.create({
        transactionId: 'txn_does_not_exist',
        reason: 'customer_request'
      })
    ).rejects.toThrow(RefundKit.NotFoundError);
  });

  it('should handle idempotent requests', async () => {
    const newTransaction = await rk.transactions.create({
      amount: 7500,
      currency: 'usd',
      customerId: 'cust_test_idempotent',
    });

    const idempotencyKey = 'test_idempotency_key_123';

    const refund1 = await rk.refunds.create(
      { transactionId: newTransaction.id, reason: 'customer_request' },
      { idempotencyKey }
    );

    const refund2 = await rk.refunds.create(
      { transactionId: newTransaction.id, reason: 'customer_request' },
      { idempotencyKey }
    );

    // Same refund returned both times
    expect(refund1.id).toBe(refund2.id);
  });
});

Testing Webhooks Locally

For local development, you need a way to receive webhooks on your machine. Use a tunneling tool or the RefundKit CLI:

# Using the RefundKit CLI to forward webhooks to localhost
refundkit listen --forward-to localhost:3000/webhooks/refundkit

This creates a temporary tunnel and registers it as a webhook endpoint. Events triggered by your test-mode operations will be forwarded to your local server. You can then verify that your webhook handler processes each event type correctly.

Monitoring

Once your refund system is in production, you need observability. Refund operations are financial operations, and financial operations require monitoring that goes beyond standard application metrics.

Key Metrics to Track

// Metrics you should be tracking
interface RefundMetrics {
  // Volume metrics
  refundsCreatedPerMinute: number;
  refundsSucceededPerMinute: number;
  refundsFailedPerMinute: number;

  // Financial metrics
  totalRefundAmountToday: number;
  averageRefundAmount: number;
  refundRatePercent: number;  // refunds / transactions

  // Latency metrics
  averageProcessingTimeMs: number;
  p95ProcessingTimeMs: number;
  p99ProcessingTimeMs: number;

  // Error metrics
  policyViolationsPerHour: number;
  processorErrorsPerHour: number;
  webhookFailuresPerHour: number;
}

Alerting Rules

Set up alerts for anomalous conditions:

// Alert configuration examples
const alerts = [
  {
    name: 'High refund rate',
    condition: 'refund_rate > 5%',
    window: '1 hour',
    severity: 'warning',
    description: 'Refund rate exceeds 5% of transactions'
  },
  {
    name: 'Refund volume spike',
    condition: 'refunds_per_hour > 3x rolling_average',
    window: '1 hour',
    severity: 'critical',
    description: 'Refund volume is 3x higher than normal'
  },
  {
    name: 'Processor errors',
    condition: 'processor_error_rate > 10%',
    window: '15 minutes',
    severity: 'critical',
    description: 'More than 10% of refunds are failing at the processor'
  },
  {
    name: 'Webhook delivery failures',
    condition: 'webhook_failure_rate > 5%',
    window: '30 minutes',
    severity: 'warning',
    description: 'Webhook delivery is failing, events may be delayed'
  },
  {
    name: 'Stuck refunds',
    condition: 'refunds_in_processing_state > 1 hour',
    window: 'continuous',
    severity: 'warning',
    description: 'Refunds stuck in processing state for over an hour'
  }
];

Building a Refund Dashboard

A refund dashboard should show both real-time operational data and historical trends:

// Fetch dashboard data from RefundKit
async function getDashboardData() {
  const [todayAnalytics, weekAnalytics, recentRefunds, pendingApprovals] =
    await Promise.all([
      rk.analytics.getRefundMetrics({ period: 'today' }),
      rk.analytics.getRefundMetrics({ period: 'last_7_days' }),
      rk.refunds.list({ limit: 20, sort: '-createdAt' }),
      rk.refunds.list({ status: 'requires_approval', limit: 50 })
    ]);

  return {
    today: {
      count: todayAnalytics.totalCount,
      amount: todayAnalytics.totalAmount,
      rate: todayAnalytics.refundRate,
      avgProcessingTime: todayAnalytics.avgProcessingTime
    },
    trend: {
      dailyCounts: weekAnalytics.dailyBreakdown.map(d => ({
        date: d.date,
        count: d.count,
        amount: d.amount
      })),
      topReasons: weekAnalytics.reasonBreakdown
    },
    recentRefunds: recentRefunds.data,
    pendingApprovals: pendingApprovals.data
  };
}

Best Practices

These practices come from real-world experience building and operating refund systems at scale.

Always Store Refund Metadata

Every refund should carry metadata that connects it to your business context. Who initiated it, why, from which system, against which order. This metadata is invaluable for debugging, reporting, and auditing.

const refund = await rk.refunds.create({
  transactionId: 'txn_abc123',
  amount: 2500,
  reason: 'product_issue',
  metadata: {
    orderId: 'order_789',
    initiatedBy: 'agent_sarah',
    initiatedVia: 'support_dashboard',
    ticketId: 'ticket_456',
    customerComment: 'Product arrived with scratched surface',
    policyApplied: 'standard_return_policy_v2'
  }
});

Use Amounts in Smallest Currency Unit

Always represent money as integers in the smallest currency unit (cents for USD, pence for GBP). Floating-point math causes rounding errors that accumulate over thousands of transactions. This is not pedantic -- a one-cent rounding error on 10,000 refunds per month is $100 per month, and more importantly, it breaks reconciliation.

// Correct: amounts in cents
const refund = await rk.refunds.create({
  transactionId: 'txn_abc123',
  amount: 4999,  // $49.99
  reason: 'customer_request'
});

// WRONG: floating point amounts
// amount: 49.99  // Will cause rounding issues

Implement Retry with Exponential Backoff

Transient errors (network timeouts, processor rate limits, temporary outages) should be retried, but not aggressively. Exponential backoff with jitter prevents thundering herd problems:

async function refundWithRetry(
  params: RefundCreateParams,
  maxRetries = 3
): Promise<Refund> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await rk.refunds.create(params);
    } catch (error) {
      // Do not retry policy violations or validation errors
      if (
        error instanceof RefundKit.PolicyViolationError ||
        error instanceof RefundKit.ValidationError
      ) {
        throw error;
      }

      // Do not retry if we have exhausted attempts
      if (attempt === maxRetries) {
        throw error;
      }

      // Exponential backoff with jitter
      const baseDelay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
      const jitter = Math.random() * 1000;
      const delay = baseDelay + jitter;

      console.warn(
        `Refund attempt ${attempt + 1} failed, retrying in ${delay}ms:`,
        error.message
      );
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  // TypeScript needs this, though it is unreachable
  throw new Error('Retry loop exited unexpectedly');
}

Separate Refund Logic from Business Logic

Your refund processing code should be isolated from your business logic. The function that decides whether a customer is eligible for a refund (business logic) should be separate from the function that actually processes the refund (infrastructure). This makes both easier to test, maintain, and change independently.

// Business logic: determines eligibility
async function evaluateRefundEligibility(
  orderId: string,
  requestedAmount: number
): Promise<EligibilityResult> {
  const order = await getOrder(orderId);
  const daysSinceDelivery = daysBetween(order.deliveredAt, new Date());

  if (daysSinceDelivery > 30) {
    return { eligible: false, reason: 'Return window has expired' };
  }

  if (order.isDigitalProduct && order.hasBeenDownloaded) {
    return { eligible: false, reason: 'Digital products are non-refundable after download' };
  }

  const maxRefundable = order.amount - order.totalRefunded;
  if (requestedAmount > maxRefundable) {
    return {
      eligible: true,
      adjustedAmount: maxRefundable,
      reason: `Maximum refundable amount is ${maxRefundable}`
    };
  }

  return { eligible: true, amount: requestedAmount };
}

// Infrastructure: processes the refund
async function processRefund(
  transactionId: string,
  amount: number,
  metadata: Record<string, string>
): Promise<Refund> {
  return refundWithRetry({
    transactionId,
    amount,
    reason: 'customer_request',
    metadata
  });
}

// Controller: orchestrates both
async function handleRefundRequest(
  orderId: string,
  requestedAmount: number
): Promise<RefundResponse> {
  const eligibility = await evaluateRefundEligibility(orderId, requestedAmount);

  if (!eligibility.eligible) {
    return { success: false, reason: eligibility.reason };
  }

  const amount = eligibility.adjustedAmount ?? eligibility.amount;
  const order = await getOrder(orderId);

  const refund = await processRefund(
    order.transactionId,
    amount,
    { orderId, source: 'customer_portal' }
  );

  return {
    success: true,
    refundId: refund.id,
    amount: refund.amount,
    estimatedArrival: refund.estimatedArrival
  };
}

Log Everything, but Redact Sensitive Data

Refund operations should generate detailed logs for debugging and auditing. But refund data contains sensitive information (customer details, financial amounts, payment method info). Redact PII and card details from logs while preserving enough information to debug issues.

function logRefundEvent(event: string, data: Record<string, unknown>) {
  const redacted = {
    ...data,
    // Redact sensitive fields
    customerEmail: data.customerEmail
      ? maskEmail(data.customerEmail as string)
      : undefined,
    cardLast4: data.cardNumber
      ? (data.cardNumber as string).slice(-4)
      : undefined,
    cardNumber: undefined,  // Never log full card numbers
  };

  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    event,
    ...redacted
  }));
}

// Usage
logRefundEvent('refund.created', {
  refundId: refund.id,
  transactionId: refund.transactionId,
  amount: refund.amount,
  currency: refund.currency,
  customerEmail: customer.email,
  reason: refund.reason
});

Common Pitfalls

These are mistakes that show up repeatedly in production refund systems:

Not handling partial refund arithmetic correctly. When a customer has already received a partial refund, the maximum remaining refund is the original amount minus all previous refunds. If you calculate this incorrectly, you will either over-refund (losing money) or under-refund (frustrating the customer and potentially violating regulations).

Ignoring currency formatting. Different currencies have different decimal places. USD has 2 (so 100 = $1.00), JPY has 0 (so 100 = 100 yen), and BHD has 3 (so 1000 = 1.000 BHD). If you assume all currencies work like USD, you will process refunds for the wrong amounts in other currencies.

Not reconciling. Your refund records and your payment processor's records should agree. Run daily reconciliation to catch discrepancies. A refund that your system thinks succeeded but the processor has no record of (or vice versa) is a problem that gets worse the longer it goes undetected.

Treating webhooks as optional. Some developers poll the refund status API instead of setting up webhooks. This is slower, more expensive (API calls cost money at scale), and creates gaps where status changes are missed between polls. Webhooks are not optional for production systems.

Conclusion

Building a reliable refund system requires attention to details that are easy to overlook: idempotency keys that prevent double refunds, webhook handlers that process events exactly once, test suites that cover error cases and edge cases, monitoring that catches anomalies before they become expensive.

The patterns in this guide -- typed error handling, deterministic idempotency, idempotent webhook processing, separation of business logic from infrastructure, comprehensive monitoring -- are not theoretical best practices. They are the minimum requirements for a refund system that operates correctly under real-world conditions: network failures, processor outages, concurrent requests, and the inevitable bugs in your own code.

Start with the basics (idempotency and webhooks), add monitoring early (not after the first incident), and build your test suite as you build the features. Refund systems that are tested and monitored from day one cause far fewer 3 AM pages than ones where these concerns were added after launch.