Skip to main content
The package provides simple methods for processing both full and partial refunds on approved payments.

Understanding refunds

Refunds allow you to return money to customers for approved payments. Key points:
  • Only approved payments can be refunded
  • You can refund the full amount or a partial amount
  • Refunds are processed immediately but may take days to appear in customer accounts
  • Each refund generates its own ID for tracking
Refunds cannot be reversed once processed. Always verify payment details before issuing refunds.

Full refunds

Refund the entire payment amount:
<?php

use Fitodac\LaravelMercadoPago\Services\RefundService;
use Illuminate\Http\JsonResponse;

final class RefundController
{
    public function store(
        string $paymentId,
        RefundService $refundService
    ): JsonResponse {
        // Full refund - pass empty array
        $refund = $refundService->create($paymentId);

        return response()->json($refund, 201);
    }
}

Partial refunds

Refund a specific amount:
public function store(
    string $paymentId,
    Request $request,
    RefundService $refundService
): JsonResponse {
    $payload = $request->validate([
        'amount' => ['required', 'numeric', 'min:0.01'],
    ]);

    $refund = $refundService->create($paymentId, $payload);

    return response()->json($refund, 201);
}

Payload structure

Full refund payload

Pass an empty object or no payload:
{}

Partial refund payload

{
  "amount": 50.00
}

Field validation rules

FieldRuleDescription
amountsometimes, numeric, min:0.01Refund amount (optional for full refund)
Validation rules are defined in src/Http/Requests/CreateRefundRequest.php

Service implementation

The RefundService handles both refund types (from src/Services/RefundService.php:15-35):
public function create(string $paymentId, array $payload = []): mixed
{
    $client = $this->clientFactory->makeFirstAvailable([
        'MercadoPago\\Client\\Payment\\PaymentRefundClient',
    ]);

    if (array_key_exists('amount', $payload)) {
        // Partial refund
        return $this->clientFactory->callFirstAvailable(
            $client,
            ['refund'],
            (int) $paymentId,
            (float) $payload['amount'],
        );
    }

    // Full refund
    return $this->clientFactory->callFirstAvailable(
        $client,
        ['refundTotal'],
        (int) $paymentId,
    );
}

Refund response

Successful refund returns:
{
  "ok": true,
  "data": {
    "id": 987654321,
    "payment_id": 123456789,
    "amount": 50.00,
    "status": "approved",
    "source": {
      "type": "collector"
    },
    "date_created": "2026-03-10T14:30:00.000-03:00",
    "unique_sequence_number": null,
    "refund_mode": "standard"
  },
  "meta": []
}

Key response fields

  • id: Refund unique identifier
  • payment_id: Original payment ID
  • amount: Refunded amount
  • status: Refund status (approved, pending, etc.)
  • date_created: When refund was processed

Common patterns

Refunding with order validation

Validate order ownership before refunding:
use App\Models\Order;
use Fitodac\LaravelMercadoPago\Services\RefundService;

public function refund(
    string $orderId,
    Request $request,
    RefundService $refundService
): JsonResponse {
    $order = Order::where('id', $orderId)
        ->where('user_id', auth()->id())
        ->firstOrFail();

    if ($order->status !== 'paid') {
        return response()->json([
            'error' => 'Only paid orders can be refunded',
        ], 422);
    }

    $refund = $refundService->create(
        $order->mercadopago_payment_id,
        $request->only(['amount'])
    );

    // Update order status
    $order->update([
        'status' => 'refunded',
        'refunded_amount' => data_get($refund, 'amount'),
        'refunded_at' => now(),
    ]);

    return response()->json($refund);
}

Partial refund with validation

Ensure refund amount doesn’t exceed payment amount:
use Fitodac\LaravelMercadoPago\Services\PaymentService;
use Fitodac\LaravelMercadoPago\Services\RefundService;

public function refund(
    string $paymentId,
    Request $request,
    PaymentService $paymentService,
    RefundService $refundService
): JsonResponse {
    // Fetch payment details
    $payment = $paymentService->get($paymentId);
    $transactionAmount = data_get($payment, 'transaction_amount');
    
    // Validate refund amount
    $refundAmount = $request->input('amount');
    
    if ($refundAmount > $transactionAmount) {
        return response()->json([
            'error' => 'Refund amount cannot exceed payment amount',
            'max_refundable' => $transactionAmount,
        ], 422);
    }
    
    // Process refund
    $refund = $refundService->create($paymentId, [
        'amount' => $refundAmount,
    ]);
    
    return response()->json($refund);
}

Multiple partial refunds

Track multiple refunds on the same payment:
use App\Models\Refund;

public function refund(
    string $paymentId,
    Request $request,
    PaymentService $paymentService,
    RefundService $refundService
): JsonResponse {
    // Get payment and existing refunds
    $payment = $paymentService->get($paymentId);
    $transactionAmount = data_get($payment, 'transaction_amount');
    
    $totalRefunded = Refund::where('payment_id', $paymentId)
        ->where('status', 'approved')
        ->sum('amount');
    
    $requestedAmount = $request->input('amount');
    $availableAmount = $transactionAmount - $totalRefunded;
    
    if ($requestedAmount > $availableAmount) {
        return response()->json([
            'error' => 'Insufficient refundable amount',
            'available' => $availableAmount,
        ], 422);
    }
    
    // Process refund
    $refund = $refundService->create($paymentId, [
        'amount' => $requestedAmount,
    ]);
    
    // Store refund record
    Refund::create([
        'payment_id' => $paymentId,
        'refund_id' => data_get($refund, 'id'),
        'amount' => data_get($refund, 'amount'),
        'status' => data_get($refund, 'status'),
    ]);
    
    return response()->json($refund);
}

Automatic refund on order cancellation

Trigger refunds when orders are cancelled:
use App\Models\Order;
use Fitodac\LaravelMercadoPago\Services\RefundService;

class CancelOrderAction
{
    public function __construct(
        private RefundService $refundService
    ) {}
    
    public function execute(Order $order): void
    {
        if ($order->status === 'paid' && $order->mercadopago_payment_id) {
            try {
                // Attempt full refund
                $refund = $this->refundService->create(
                    $order->mercadopago_payment_id
                );
                
                $order->update([
                    'status' => 'refunded',
                    'refund_id' => data_get($refund, 'id'),
                    'refunded_at' => now(),
                ]);
            } catch (\Exception $e) {
                \Log::error('Refund failed for order ' . $order->id, [
                    'error' => $e->getMessage(),
                ]);
                
                throw $e;
            }
        } else {
            $order->update(['status' => 'cancelled']);
        }
    }
}

Refund lifecycle

1

Request refund

Call RefundService::create() with payment ID and optional amount
2

MercadoPago processes

MercadoPago validates and processes the refund request
3

Refund approved

Refund status changes to approved (usually immediate)
4

Funds returned

Money returns to customer’s account (may take 3-30 days depending on payment method)
5

Webhook notification

MercadoPago sends webhook with refund event

Handling refund webhooks

MercadoPago sends webhooks when refunds are processed:
use Fitodac\LaravelMercadoPago\Services\WebhookService;
use Fitodac\LaravelMercadoPago\Services\PaymentService;

public function webhook(
    Request $request,
    WebhookService $webhookService,
    PaymentService $paymentService
): JsonResponse {
    $result = $webhookService->handle($request);
    
    if ($result['topic'] === 'payment') {
        // Fetch current payment status
        $payment = $paymentService->get($result['resource']);
        $status = data_get($payment, 'status');
        
        // Check if payment was refunded
        if ($status === 'refunded') {
            $externalRef = data_get($payment, 'external_reference');
            
            Order::where('reference', $externalRef)->update([
                'status' => 'refunded',
                'refunded_at' => now(),
            ]);
        }
    }
    
    return response()->json(['ok' => true]);
}

Error handling

Payment not found

try {
    $refund = $refundService->create($paymentId);
} catch (\Exception $e) {
    if (str_contains($e->getMessage(), 'not found')) {
        return response()->json([
            'error' => 'Payment not found',
        ], 404);
    }
    
    throw $e;
}

Payment not refundable

try {
    $refund = $refundService->create($paymentId);
} catch (\Exception $e) {
    if (str_contains($e->getMessage(), 'cannot be refunded')) {
        return response()->json([
            'error' => 'Payment cannot be refunded',
            'reason' => 'Payment must be approved to process refund',
        ], 422);
    }
    
    throw $e;
}

Best practices

Always validate before refunding

Verify payment status and amount before processing refunds to prevent errors and disputes.
$payment = $paymentService->get($paymentId);

if (data_get($payment, 'status') !== 'approved') {
    throw new \Exception('Only approved payments can be refunded');
}

Log all refund operations

\Log::info('Processing refund', [
    'payment_id' => $paymentId,
    'amount' => $refundAmount ?? 'full',
    'user_id' => auth()->id(),
]);

$refund = $refundService->create($paymentId, $payload);

\Log::info('Refund processed', [
    'refund_id' => data_get($refund, 'id'),
    'status' => data_get($refund, 'status'),
]);

Notify customers

Send email notifications when refunds are processed:
use App\Mail\RefundProcessed;

$refund = $refundService->create($paymentId);

Mail::to($order->customer_email)->send(
    new RefundProcessed($order, $refund)
);

Testing refunds

Use test credentials and test payments to verify refund functionality before deploying to production.
Test with demo endpoints:
# Create test payment
curl --request POST \
  --url http://localhost:8000/api/mercadopago/payments \
  --header 'Content-Type: application/json' \
  --data '{...}'

# Refund the payment
curl --request POST \
  --url http://localhost:8000/api/mercadopago/payments/PAYMENT_ID/refunds \
  --header 'Content-Type: application/json' \
  --data '{}'

Next steps

Processing Payments

Learn about payment creation

Handling Webhooks

Handle refund notifications

Testing

Test refund workflows locally

Build docs developers (and LLMs) love