Perbedaan Midtrans dan Xendit Payment Gateway Serta Kemudahan Integrasi Projek Website Laravel

Panduan lengkap perbandingan Midtrans vs Xendit untuk developer Laravel. Pelajari perbedaan fitur, pricing, settlement time, dan cara integrasi payment gateway ke projek website Laravel dengan code best practice yang siap production.

Bagian 1: Kenapa Pilih Payment Gateway Itu Penting Banget?

Yo, saya Angga — Founder BuildWithAngga.

Selama bertahun-tahun develop projek untuk client dan platform sendiri, satu pertanyaan yang selalu muncul di awal projek e-commerce: "Mas, pakai payment gateway apa ya?"

Dan jawabannya hampir selalu: **Midtrans atau Xendit.**

Dua nama ini udah jadi "standar industri" di Indonesia. Tapi masalahnya, banyak developer yang asal pilih tanpa paham perbedaannya. Ujung-ujungnya? Salah pilih, ribet migrate, buang waktu.

Cerita Singkat

Dulu waktu pertama kali bikin fitur pembayaran untuk kelas online BuildWithAngga, saya langsung pilih Midtrans. Kenapa? Karena waktu itu yang saya tahu cuma Midtrans. Ternyata keputusan itu tepat — mayoritas student bayar pakai GoPay dan Virtual Account BCA. Midtrans support keduanya dengan sangat baik.

Tapi cerita berbeda waktu develop marketplace untuk client. Mereka butuh fitur bayar otomatis ke seller setiap ada transaksi sukses. Midtrans nggak punya fitur itu. Akhirnya harus pakai Xendit untuk disbursement-nya.

Pelajaran? Pilih payment gateway berdasarkan kebutuhan bisnis, bukan popularitas.

Payment Gateway Itu Kayak Apa Sih?

Biar gampang, anggap aja payment gateway itu kasir digital.

Kalau kamu punya toko fisik, kasir yang terima uang dari customer. Di dunia online, payment gateway yang handle itu semua — terima transfer, proses kartu kredit, generate QRIS, kirim notifikasi kalau sudah bayar.

Bedanya Midtrans dan Xendit?

Midtrans itu kasir yang udah kenal semua customer GoPay — transaksi lebih smooth karena satu ekosistem GoTo. Xendit itu kasir yang bisa sekalian antar uang ke supplier — lebih versatile karena ada fitur disbursement.

Data Terkini: Payment Digital Indonesia 2025

Sebelum lanjut, lihat dulu data dari Bank Indonesia Q2 2025:

MetrikAngkaGrowth
Pengguna QRIS57 juta orang
Transaksi Kartu Kredit+7.28% YoY
Transaksi PayLater+32.18% YoY

Artinya? Payment digital makin dominan. Bisnis online tanpa payment gateway yang proper = kehilangan mayoritas potential customer.

Customer sekarang maunya instant. Kalau harus transfer manual terus konfirmasi via WhatsApp? Bye bye, pindah ke kompetitor.

Apa yang Akan Kita Bahas?

Di artikel ini, saya akan breakdown:

  1. Perbandingan fitur lengkap (metode pembayaran, pricing, settlement)
  2. Setup dan konfigurasi di Laravel
  3. Implementasi Midtrans Snap (popup checkout)
  4. Implementasi Xendit Invoice (redirect checkout)
  5. Perbandingan code dan developer experience
  6. Kapan pilih Midtrans vs Xendit

Semua dengan code yang siap production — bukan tutorial toy project yang nggak bisa dipake di dunia nyata.

Siapa yang Cocok Baca Artikel Ini?

  • Developer yang mau implement payment gateway pertama kali
  • Developer yang bingung pilih Midtrans atau Xendit
  • Developer yang mau migrate dari satu gateway ke gateway lain
  • Founder/CTO yang mau understand trade-off sebelum decide

Kalau kamu salah satu di atas, let's go. 🚀

💡 Mini Tips: Sebelum pilih payment gateway, list dulu metode pembayaran yang paling sering dipakai target customer kamu. Survey singkat ke 10-20 potential customer bisa save kamu dari salah pilih.


Bagian 2: Head-to-Head — Midtrans vs Xendit

Oke, sekarang kita masuk ke perbandingan detail. Saya akan breakdown dari berbagai aspek biar kamu bisa nilai sendiri mana yang lebih cocok.

Company Background

AspekMidtransXendit
Didirikan20122015
Parent CompanyGoTo Group (Gojek + Tokopedia)Standalone (Y Combinator backed)
CoverageIndonesiaIndonesia, Filipina, Vietnam
RegulasiLicensed by Bank IndonesiaLicensed by Bank Indonesia

Insight: Midtrans bagian dari GoTo berarti integrasi native dengan GoPay — e-wallet terbesar di Indonesia. Xendit standalone tapi punya investor kelas dunia dan ekspansi regional.

Metode Pembayaran

Ini yang paling penting. Percuma payment gateway canggih kalau metode pembayaran yang customer mau nggak ada.

MetodeMidtransXendit
Virtual Account
BCA
BNI
BRI
Mandiri
Permata
CIMB
E-Wallet
GoPay✅ Native
OVO
DANA
ShopeePay
LinkAja
Lainnya
QRIS
Kartu Kredit/Debit✅ Visa, Master, JCB, Amex✅ Visa, Master, JCB
Alfamart/Indomaret
PayLater (Kredivo, Akulaku)
Direct Debit

Verdict: Hampir sama, kecuali GoPay. Midtrans support GoPay native, Xendit tidak. Kalau target market kamu heavy user Gojek/Tokopedia, ini consideration penting.

Pricing (Biaya per Transaksi)

Ini yang sering jadi pertanyaan: "Mana yang lebih murah?"

MetodeMidtransXendit
Virtual AccountRp 4.000 - 4.500Rp 4.500 - 5.000
QRIS0.7% (MDR)0.7% (MDR)
GoPay2%
OVO2%1.5% - 2%
DANA2%2%
ShopeePay2%2%
Kartu Kredit Domestik2.9% + Rp 2.0002.9% + Rp 2.000
Kartu Kredit International3.9% + Rp 2.0003.5% + Rp 2.000
Alfamart/IndomaretRp 5.000Rp 5.000

Note: Harga bisa negotiable tergantung volume transaksi. Ini rate standar untuk merchant baru.

Verdict: Hampir sama. Selisih tipis. Jangan pilih payment gateway cuma karena beda Rp 500 per transaksi. Fokus ke fitur dan reliability.

Settlement Time (Kapan Uang Masuk?)

Ini penting untuk cash flow bisnis.

MetodeMidtransXendit
Virtual AccountT+1 (next business day)T+2
QRIST+1T+1
E-WalletT+1T+1
Kartu KreditT+2T+7
Retail (Alfamart)T+2T+2

Verdict: Midtrans menang di settlement time. Uang lebih cepat masuk ke rekening. Untuk bisnis dengan margin tipis atau butuh cash flow cepat, ini significant.

Fitur Tambahan

Di luar terima pembayaran, apa lagi yang ditawarkan?

FiturMidtransXendit
Disbursement (kirim uang)
Recurring Payment
Payment Link
Invoice
Subscription Billing
Fraud Detection✅ AI-based✅ + 3DS
Split Payment✅ (PayOuts)
Multi-currency
Batch PayoutLimited
Virtual Account Aggregator

The Big Difference: Disbursement

Xendit punya fitur Disbursement — kemampuan untuk mengirim uang ke rekening bank lain secara programmatic.

Use case:

  • Marketplace: Otomatis bayar seller setelah transaksi complete
  • Payroll system: Kirim gaji ke banyak rekening sekaligus
  • Refund: Kembalikan uang ke customer
  • Vendor payment: Bayar supplier otomatis

Midtrans tidak punya fitur ini. Fokus mereka murni di "terima pembayaran".

Kalau bisnis kamu butuh fitur kirim uang (bukan cuma terima), Xendit adalah pilihan yang lebih logical.

Developer Experience

Sebagai developer, ini yang saya perhatikan:

AspekMidtransXendit
Dokumentasi⭐⭐⭐⭐ Bahasa Indonesia⭐⭐⭐⭐⭐ English, lebih clean
SDK/LibraryOfficial PHP, Node, dllOfficial PHP, Node, dll
Sandbox Testing⭐⭐⭐⭐⭐ Lengkap⭐⭐⭐⭐ Lengkap
Dashboard UX⭐⭐⭐⭐ Simple⭐⭐⭐⭐⭐ Modern
API DesignREST, cukup cleanREST, very clean
Webhook Reliability⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Customer Support⭐⭐⭐⭐⭐ Responsive⭐⭐⭐⭐ Responsive
Community Resources⭐⭐⭐⭐⭐ Banyak tutorial Indo⭐⭐⭐⭐ Growing

My Take: Xendit API lebih modern dan clean. Tapi Midtrans punya lebih banyak tutorial Bahasa Indonesia dan community yang established. Untuk developer pemula, Midtrans mungkin lebih mudah karena resource belajarnya lebih banyak.

Summary: Kapan Pilih Mana?

Pilih Midtrans kalau:

  • Target customer heavy user GoPay
  • Butuh settlement cepat (T+1)
  • Prefer dokumentasi Bahasa Indonesia
  • Sudah pakai ekosistem GoTo
  • Fokus pure e-commerce (terima pembayaran saja)

Pilih Xendit kalau:

  • Butuh fitur disbursement (bayar ke seller/vendor)
  • Target market regional (Filipina, Vietnam)
  • Prefer API yang lebih modern
  • Building marketplace atau platform kompleks
  • Butuh batch payout untuk payroll/vendor

Atau... pakai keduanya?

Banyak bisnis besar yang combine:

  • Midtrans untuk terima pembayaran (GoPay support)
  • Xendit untuk disbursement ke vendor

Nggak ada rule yang bilang harus pilih satu. Yang penting arsitekturnya clean.

💡 Mini Tips: Daftar akun sandbox di kedua platform (gratis). Coba API-nya langsung. 30 menit eksperimen lebih valuable daripada 3 jam baca artikel perbandingan.

Di bagian selanjutnya, kita masuk ke code. Saya akan tunjukkan cara setup dan implementasi keduanya di Laravel dengan pattern yang production-ready.

Bagian 3: Setup & Konfigurasi Laravel

Oke, sekarang kita masuk ke bagian yang developer suka — coding.

Saya akan tunjukkan cara setup kedua payment gateway di Laravel 11 dengan pattern yang clean dan production-ready. Bukan tutorial asal jalan, tapi best practice yang bisa langsung kamu pakai di projek real.

Prerequisites

Sebelum mulai, pastikan kamu sudah punya:

Install Package

# Midtrans Official PHP Library
composer require midtrans/midtrans-php

# Xendit Official PHP Library
composer require xendit/xendit-php

Kedua package ini official dari masing-masing provider. Maintained dengan baik dan selalu update mengikuti API terbaru.

Konfigurasi Midtrans

Pertama, buat config file untuk Midtrans:

// config/midtrans.php
<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Midtrans Merchant ID
    |--------------------------------------------------------------------------
    */
    'merchant_id' => env('MIDTRANS_MERCHANT_ID'),

    /*
    |--------------------------------------------------------------------------
    | Midtrans Client Key (untuk frontend/Snap)
    |--------------------------------------------------------------------------
    */
    'client_key' => env('MIDTRANS_CLIENT_KEY'),

    /*
    |--------------------------------------------------------------------------
    | Midtrans Server Key (untuk backend API calls)
    |--------------------------------------------------------------------------
    */
    'server_key' => env('MIDTRANS_SERVER_KEY'),

    /*
    |--------------------------------------------------------------------------
    | Environment Setting
    |--------------------------------------------------------------------------
    | Set true untuk production, false untuk sandbox
    */
    'is_production' => env('MIDTRANS_IS_PRODUCTION', false),

    /*
    |--------------------------------------------------------------------------
    | Sanitization
    |--------------------------------------------------------------------------
    | Otomatis sanitize input untuk keamanan
    */
    'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),

    /*
    |--------------------------------------------------------------------------
    | 3D Secure
    |--------------------------------------------------------------------------
    | Enable 3DS untuk transaksi kartu kredit (recommended)
    */
    'is_3ds' => env('MIDTRANS_IS_3DS', true),

    /*
    |--------------------------------------------------------------------------
    | Snap URL
    |--------------------------------------------------------------------------
    */
    'snap_url' => env('MIDTRANS_IS_PRODUCTION', false)
        ? '<https://app.midtrans.com/snap/snap.js>'
        : '<https://app.sandbox.midtrans.com/snap/snap.js>',
];

Tambahkan environment variables:

# .env
MIDTRANS_MERCHANT_ID=G123456789
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxxxxx
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true

Dapat dari mana? Login ke dashboard.sandbox.midtrans.com → Settings → Access Keys.

Konfigurasi Xendit

// config/xendit.php
<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Xendit Secret Key (untuk API calls)
    |--------------------------------------------------------------------------
    */
    'secret_key' => env('XENDIT_SECRET_KEY'),

    /*
    |--------------------------------------------------------------------------
    | Xendit Public Key (untuk frontend jika diperlukan)
    |--------------------------------------------------------------------------
    */
    'public_key' => env('XENDIT_PUBLIC_KEY'),

    /*
    |--------------------------------------------------------------------------
    | Webhook Verification Token
    |--------------------------------------------------------------------------
    | Untuk verify bahwa webhook benar dari Xendit
    */
    'webhook_token' => env('XENDIT_WEBHOOK_TOKEN'),

    /*
    |--------------------------------------------------------------------------
    | Environment Setting
    |--------------------------------------------------------------------------
    */
    'is_production' => env('XENDIT_IS_PRODUCTION', false),
];

# .env
XENDIT_SECRET_KEY=xnd_development_xxxxxxxxxxxxxxxxxxxx
XENDIT_PUBLIC_KEY=xnd_public_development_xxxxxxxxxxxx
XENDIT_WEBHOOK_TOKEN=your-webhook-verification-token
XENDIT_IS_PRODUCTION=false

Dapat dari mana? Login ke dashboard.xendit.co → Settings → API Keys.

Service Provider (Best Practice)

Daripada configure di setiap controller, lebih clean kalau kita setup sekali di Service Provider:

// app/Providers/PaymentServiceProvider.php
<?php

namespace App\\Providers;

use Illuminate\\Support\\ServiceProvider;
use Midtrans\\Config as MidtransConfig;
use Xendit\\Configuration as XenditConfig;

class PaymentServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureMidtrans();
        $this->configureXendit();
    }

    /**
     * Configure Midtrans SDK
     */
    private function configureMidtrans(): void
    {
        MidtransConfig::$serverKey = config('midtrans.server_key');
        MidtransConfig::$isProduction = config('midtrans.is_production');
        MidtransConfig::$isSanitized = config('midtrans.is_sanitized');
        MidtransConfig::$is3ds = config('midtrans.is_3ds');
    }

    /**
     * Configure Xendit SDK
     */
    private function configureXendit(): void
    {
        XenditConfig::setXenditKey(config('xendit.secret_key'));
    }
}

Register provider di bootstrap/providers.php:

// bootstrap/providers.php
<?php

return [
    App\\Providers\\AppServiceProvider::class,
    App\\Providers\\PaymentServiceProvider::class, // Tambahkan ini
];

Dengan setup ini, setiap kali aplikasi boot, Midtrans dan Xendit sudah auto-configured. Kamu tinggal panggil API-nya di mana saja tanpa perlu setup ulang.

Database Migration

Kita butuh table untuk menyimpan data order dan payment:

// database/migrations/xxxx_xx_xx_create_orders_table.php
<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();

            // Order info
            $table->string('order_number')->unique();
            $table->decimal('total_amount', 12, 2);
            $table->text('notes')->nullable();

            // Payment gateway fields
            $table->string('payment_gateway')->nullable(); // 'midtrans' atau 'xendit'
            $table->string('snap_token')->nullable(); // Midtrans Snap Token
            $table->string('xendit_invoice_id')->nullable(); // Xendit Invoice ID
            $table->string('xendit_invoice_url')->nullable(); // Xendit Payment URL

            // Payment status
            $table->enum('payment_status', [
                'pending',
                'paid',
                'failed',
                'expired',
                'refunded',
                'cancelled'
            ])->default('pending');
            $table->string('payment_method')->nullable(); // gopay, bca_va, credit_card, dll
            $table->timestamp('paid_at')->nullable();

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

// database/migrations/xxxx_xx_xx_create_order_items_table.php
<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('order_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('product_name'); // Snapshot nama produk
            $table->decimal('price', 12, 2); // Snapshot harga saat order
            $table->integer('quantity');
            $table->decimal('subtotal', 12, 2);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_items');
    }
};

Jalankan migration:

php artisan migrate

Model Setup

// app/Models/Order.php
<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Order extends Model
{
    protected $fillable = [
        'user_id',
        'order_number',
        'total_amount',
        'notes',
        'payment_gateway',
        'snap_token',
        'xendit_invoice_id',
        'xendit_invoice_url',
        'payment_status',
        'payment_method',
        'paid_at',
    ];

    protected $casts = [
        'total_amount' => 'decimal:2',
        'paid_at' => 'datetime',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    /**
     * Generate unique order number
     */
    public static function generateOrderNumber(): string
    {
        $prefix = 'INV';
        $date = now()->format('Ymd');
        $random = strtoupper(substr(uniqid(), -5));

        return "{$prefix}-{$date}-{$random}";
    }

    /**
     * Check if order is payable
     */
    public function isPayable(): bool
    {
        return $this->payment_status === 'pending';
    }

    /**
     * Check if order is paid
     */
    public function isPaid(): bool
    {
        return $this->payment_status === 'paid';
    }
}

// app/Models/OrderItem.php
<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

class OrderItem extends Model
{
    protected $fillable = [
        'order_id',
        'product_id',
        'product_name',
        'price',
        'quantity',
        'subtotal',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'subtotal' => 'decimal:2',
    ];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }
}

Setup selesai! Sekarang kita siap implement payment gateway.

💡 Mini Tips: Selalu simpan payment_gateway di table order. Ini penting kalau nanti kamu mau support multiple gateway — kamu tahu order ini dibayar via Midtrans atau Xendit, dan bisa handle webhook dengan benar.


Bagian 4: Implementasi Midtrans — Snap Checkout

Midtrans Snap adalah cara paling populer untuk implement payment di Indonesia. User tidak perlu leave website — popup muncul di halaman yang sama, pilih metode pembayaran, bayar, done.

Flow Pembayaran Midtrans Snap

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Customer  │     │   Laravel   │     │   Midtrans  │
│   Browser   │     │   Backend   │     │     API     │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │
       │  1. Klik "Bayar"  │                   │
       │──────────────────>│                   │
       │                   │                   │
       │                   │ 2. Request Token  │
       │                   │──────────────────>│
       │                   │                   │
       │                   │ 3. Return Token   │
       │                   │<──────────────────│
       │                   │                   │
       │ 4. Render Popup   │                   │
       │<──────────────────│                   │
       │                   │                   │
       │ 5. Customer Bayar │                   │
       │───────────────────────────────────────>
       │                   │                   │
       │                   │ 6. Webhook Notif  │
       │                   │<──────────────────│
       │                   │                   │
       │ 7. Success Page   │                   │
       │<──────────────────│                   │
       │                   │                   │

Service Class

Saya suka pisahkan logic payment ke Service class. Controller tetap clean, dan logic bisa di-reuse di mana saja.

// app/Services/MidtransService.php
<?php

namespace App\\Services;

use App\\Models\\Order;
use Midtrans\\Snap;
use Midtrans\\Notification;
use Illuminate\\Support\\Str;
use Illuminate\\Support\\Facades\\Log;

class MidtransService
{
    /**
     * Create Snap Token untuk order
     */
    public function createSnapToken(Order $order): string
    {
        $params = [
            'transaction_details' => [
                'order_id' => $order->order_number,
                'gross_amount' => (int) $order->total_amount,
            ],
            'customer_details' => [
                'first_name' => $order->user->name,
                'email' => $order->user->email,
                'phone' => $order->user->phone ?? '',
            ],
            'item_details' => $this->formatItemDetails($order),
            'callbacks' => [
                'finish' => route('checkout.finish', $order),
            ],
        ];

        try {
            $snapToken = Snap::getSnapToken($params);

            // Simpan token ke database
            $order->update([
                'snap_token' => $snapToken,
                'payment_gateway' => 'midtrans',
            ]);

            return $snapToken;

        } catch (\\Exception $e) {
            Log::error('Midtrans Snap Token Error', [
                'order_id' => $order->id,
                'message' => $e->getMessage(),
            ]);

            throw $e;
        }
    }

    /**
     * Format item details untuk Midtrans
     */
    private function formatItemDetails(Order $order): array
    {
        return $order->items->map(function ($item) {
            return [
                'id' => (string) $item->product_id,
                'price' => (int) $item->price,
                'quantity' => $item->quantity,
                'name' => Str::limit($item->product_name, 50), // Max 50 char
            ];
        })->toArray();
    }

    /**
     * Handle notification dari Midtrans webhook
     */
    public function handleNotification(array $payload): Order
    {
        $orderId = $payload['order_id'];
        $transactionStatus = $payload['transaction_status'];
        $paymentType = $payload['payment_type'];
        $fraudStatus = $payload['fraud_status'] ?? 'accept';

        $order = Order::where('order_number', $orderId)->firstOrFail();

        // Log untuk debugging
        Log::info('Midtrans Notification', [
            'order_number' => $orderId,
            'status' => $transactionStatus,
            'payment_type' => $paymentType,
            'fraud_status' => $fraudStatus,
        ]);

        // Handle berbagai status
        if ($transactionStatus === 'capture') {
            // Untuk kartu kredit
            if ($fraudStatus === 'accept') {
                $this->markAsPaid($order, $paymentType);
            } else {
                $this->markAsFailed($order);
            }
        } elseif ($transactionStatus === 'settlement') {
            // Untuk VA, e-wallet, dll
            $this->markAsPaid($order, $paymentType);
        } elseif ($transactionStatus === 'pending') {
            // Menunggu pembayaran
            $order->update(['payment_status' => 'pending']);
        } elseif (in_array($transactionStatus, ['deny', 'cancel'])) {
            $this->markAsFailed($order);
        } elseif ($transactionStatus === 'expire') {
            $order->update(['payment_status' => 'expired']);
        } elseif ($transactionStatus === 'refund') {
            $order->update(['payment_status' => 'refunded']);
        }

        return $order->fresh();
    }

    /**
     * Mark order as paid
     */
    private function markAsPaid(Order $order, string $paymentType): void
    {
        $order->update([
            'payment_status' => 'paid',
            'payment_method' => $paymentType,
            'paid_at' => now(),
        ]);

        // TODO: Trigger event untuk kirim email, update stock, dll
        // event(new OrderPaid($order));
    }

    /**
     * Mark order as failed
     */
    private function markAsFailed(Order $order): void
    {
        $order->update(['payment_status' => 'failed']);
    }

    /**
     * Verify signature dari webhook
     */
    public function verifySignature(array $payload): bool
    {
        $serverKey = config('midtrans.server_key');

        $signatureKey = hash('sha512',
            $payload['order_id'] .
            $payload['status_code'] .
            $payload['gross_amount'] .
            $serverKey
        );

        return $payload['signature_key'] === $signatureKey;
    }
}

Controller

// app/Http/Controllers/CheckoutController.php
<?php

namespace App\\Http\\Controllers;

use App\\Models\\Order;
use App\\Services\\MidtransService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Log;

class CheckoutController extends Controller
{
    public function __construct(
        private MidtransService $midtransService
    ) {}

    /**
     * Show payment page dengan Snap popup
     */
    public function payment(Order $order)
    {
        // Pastikan order milik user yang login
        if ($order->user_id !== auth()->id()) {
            abort(403);
        }

        // Pastikan order masih bisa dibayar
        if (!$order->isPayable()) {
            return redirect()
                ->route('orders.show', $order)
                ->with('error', 'Order ini sudah tidak bisa dibayar.');
        }

        // Generate Snap token jika belum ada
        if (!$order->snap_token) {
            try {
                $this->midtransService->createSnapToken($order);
                $order->refresh();
            } catch (\\Exception $e) {
                Log::error('Failed to create Snap token', [
                    'order_id' => $order->id,
                    'error' => $e->getMessage(),
                ]);

                return back()->with('error', 'Gagal memproses pembayaran. Silakan coba lagi.');
            }
        }

        return view('checkout.payment', [
            'order' => $order,
            'snapToken' => $order->snap_token,
            'clientKey' => config('midtrans.client_key'),
            'snapUrl' => config('midtrans.snap_url'),
        ]);
    }

    /**
     * Callback setelah selesai dari Snap popup
     */
    public function finish(Order $order)
    {
        $order->refresh();

        if ($order->isPaid()) {
            return redirect()
                ->route('orders.show', $order)
                ->with('success', 'Pembayaran berhasil! Terima kasih.');
        }

        return redirect()
            ->route('orders.show', $order)
            ->with('info', 'Menunggu konfirmasi pembayaran...');
    }
}

Webhook Controller

Ini bagian paling penting — handle notifikasi dari Midtrans:

// app/Http/Controllers/Webhooks/MidtransWebhookController.php
<?php

namespace App\\Http\\Controllers\\Webhooks;

use App\\Http\\Controllers\\Controller;
use App\\Services\\MidtransService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Log;

class MidtransWebhookController extends Controller
{
    public function __construct(
        private MidtransService $midtransService
    ) {}

    /**
     * Handle incoming webhook dari Midtrans
     */
    public function handle(Request $request)
    {
        $payload = $request->all();

        Log::info('Midtrans Webhook Received', $payload);

        // Verify signature untuk keamanan
        if (!$this->midtransService->verifySignature($payload)) {
            Log::warning('Midtrans Webhook: Invalid signature', $payload);
            return response()->json(['error' => 'Invalid signature'], 403);
        }

        try {
            $order = $this->midtransService->handleNotification($payload);

            return response()->json([
                'status' => 'ok',
                'order_id' => $order->order_number,
                'payment_status' => $order->payment_status,
            ]);

        } catch (\\Exception $e) {
            Log::error('Midtrans Webhook Error', [
                'message' => $e->getMessage(),
                'payload' => $payload,
            ]);

            return response()->json(['error' => 'Processing failed'], 500);
        }
    }
}

Routes

// routes/web.php
use App\\Http\\Controllers\\CheckoutController;

Route::middleware('auth')->group(function () {
    Route::get('/checkout/{order}/payment', [CheckoutController::class, 'payment'])
        ->name('checkout.payment');
    Route::get('/checkout/{order}/finish', [CheckoutController::class, 'finish'])
        ->name('checkout.finish');
});

// routes/api.php
use App\\Http\\Controllers\\Webhooks\\MidtransWebhookController;

// Webhook harus tanpa auth dan CSRF
Route::post('/webhooks/midtrans', [MidtransWebhookController::class, 'handle'])
    ->name('webhooks.midtrans');

Jangan lupa exclude dari CSRF verification:

// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'api/webhooks/*',
    ]);
})

Blade View

<!-- resources/views/checkout/payment.blade.php -->
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pembayaran - {{ $order->order_number }}</title>
    <script src="{{ $snapUrl }}" data-client-key="{{ $clientKey }}"></script>
    <script src="<https://cdn.tailwindcss.com>"></script>
</head>
<body class="bg-gray-100 min-h-screen">
    <div class="container mx-auto px-4 py-8">
        <div class="max-w-lg mx-auto bg-white rounded-lg shadow-md p-6">

            <!-- Order Summary -->
            <h1 class="text-2xl font-bold mb-4">Pembayaran</h1>

            <div class="border-b pb-4 mb-4">
                <p class="text-gray-600">Order Number</p>
                <p class="font-semibold">{{ $order->order_number }}</p>
            </div>

            <div class="space-y-2 mb-4">
                @foreach($order->items as $item)
                <div class="flex justify-between">
                    <span>{{ $item->product_name }} x {{ $item->quantity }}</span>
                    <span>Rp {{ number_format($item->subtotal, 0, ',', '.') }}</span>
                </div>
                @endforeach
            </div>

            <div class="border-t pt-4 mb-6">
                <div class="flex justify-between text-lg font-bold">
                    <span>Total</span>
                    <span>Rp {{ number_format($order->total_amount, 0, ',', '.') }}</span>
                </div>
            </div>

            <!-- Pay Button -->
            <button
                id="pay-button"
                class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition duration-200"
            >
                Bayar Sekarang
            </button>

            <p class="text-center text-gray-500 text-sm mt-4">
                Pembayaran diproses secara aman oleh Midtrans
            </p>
        </div>
    </div>

    <script>
        document.getElementById('pay-button').addEventListener('click', function() {
            // Disable button untuk prevent double click
            this.disabled = true;
            this.textContent = 'Memproses...';

            snap.pay('{{ $snapToken }}', {
                onSuccess: function(result) {
                    console.log('Payment success:', result);
                    window.location.href = '{{ route("checkout.finish", $order) }}';
                },
                onPending: function(result) {
                    console.log('Payment pending:', result);
                    window.location.href = '{{ route("checkout.finish", $order) }}';
                },
                onError: function(result) {
                    console.error('Payment error:', result);
                    alert('Pembayaran gagal. Silakan coba lagi.');
                    location.reload();
                },
                onClose: function() {
                    console.log('Popup closed');
                    // Re-enable button jika user close popup
                    document.getElementById('pay-button').disabled = false;
                    document.getElementById('pay-button').textContent = 'Bayar Sekarang';
                }
            });
        });
    </script>
</body>
</html>

Testing di Sandbox

Midtrans menyediakan test credentials untuk sandbox:

Virtual Account:

  • Pilih bank manapun
  • Klik "Lihat Nomor VA"
  • Di dashboard sandbox, ada simulator untuk complete payment

Kartu Kredit (Test Card):

Card Number: 4811 1111 1111 1114
Expiry: Any future date (e.g., 12/25)
CVV: 123
OTP: 112233

GoPay:

  • Akan muncul QR code
  • Di sandbox, langsung approve via simulator di dashboard

Set Webhook URL di Dashboard Midtrans

  1. Login ke dashboard.sandbox.midtrans.com
  2. Settings → Configuration
  3. Payment Notification URL: https://yourdomain.com/api/webhooks/midtrans
  4. Untuk local testing, gunakan ngrok:
ngrok http 8000
# Copy URL: <https://xxxx.ngrok-free.app>
# Set: <https://xxxx.ngrok-free.app/api/webhooks/midtrans>

💡 Mini Tips: Selalu verify signature di webhook handler. Tanpa ini, siapapun bisa kirim fake POST request ke endpoint kamu dan mark order sebagai "paid" — padahal belum bayar beneran. Ini security hole yang sering kelewat.

Di bagian selanjutnya, kita implement Xendit dengan approach yang sama — clean, production-ready, dan siap deploy.

Bagian 5: Implementasi Xendit — Invoice API

Kalau Midtrans pakai popup (Snap), Xendit lebih straightforward — user di-redirect ke halaman payment Xendit, bayar, lalu redirect balik ke website kamu.

Approach ini lebih simple dari sisi code, dan halaman payment Xendit sudah mobile-optimized out of the box.

Flow Pembayaran Xendit Invoice

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Customer  │     │   Laravel   │     │   Xendit    │
│   Browser   │     │   Backend   │     │     API     │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │
       │  1. Klik "Bayar"  │                   │
       │──────────────────>│                   │
       │                   │                   │
       │                   │ 2. Create Invoice │
       │                   │──────────────────>│
       │                   │                   │
       │                   │ 3. Return URL     │
       │                   │<──────────────────│
       │                   │                   │
       │ 4. Redirect to Xendit                 │
       │<──────────────────│                   │
       │                   │                   │
       │ 5. Customer Bayar di Xendit           │
       │───────────────────────────────────────>
       │                   │                   │
       │ 6. Redirect Back  │                   │
       │<──────────────────────────────────────│
       │                   │                   │
       │                   │ 7. Webhook Notif  │
       │                   │<──────────────────│
       │                   │                   │

Lebih simple kan? Tidak perlu handle popup JavaScript.

Service Class

// app/Services/XenditService.php
<?php

namespace App\\Services;

use App\\Models\\Order;
use Xendit\\Configuration;
use Xendit\\Invoice\\InvoiceApi;
use Xendit\\Invoice\\CreateInvoiceRequest;
use Illuminate\\Support\\Facades\\Log;

class XenditService
{
    private InvoiceApi $invoiceApi;

    public function __construct()
    {
        $this->invoiceApi = new InvoiceApi();
    }

    /**
     * Create Invoice untuk order
     */
    public function createInvoice(Order $order): array
    {
        $request = new CreateInvoiceRequest([
            'external_id' => $order->order_number,
            'amount' => (int) $order->total_amount,
            'description' => "Pembayaran Order #{$order->order_number}",
            'invoice_duration' => 86400, // 24 jam dalam detik
            'customer' => [
                'given_names' => $order->user->name,
                'email' => $order->user->email,
                'mobile_number' => $this->formatPhoneNumber($order->user->phone),
            ],
            'items' => $this->formatItems($order),
            'success_redirect_url' => route('xendit.success', $order),
            'failure_redirect_url' => route('xendit.failed', $order),
            'currency' => 'IDR',
            'payment_methods' => [
                'BCA', 'BNI', 'BRI', 'MANDIRI', 'PERMATA',
                'OVO', 'DANA', 'SHOPEEPAY', 'LINKAJA',
                'QRIS', 'CREDIT_CARD',
            ],
        ]);

        try {
            $invoice = $this->invoiceApi->createInvoice($request);

            // Simpan invoice data ke database
            $order->update([
                'payment_gateway' => 'xendit',
                'xendit_invoice_id' => $invoice['id'],
                'xendit_invoice_url' => $invoice['invoice_url'],
            ]);

            Log::info('Xendit Invoice Created', [
                'order_number' => $order->order_number,
                'invoice_id' => $invoice['id'],
            ]);

            return [
                'invoice_id' => $invoice['id'],
                'invoice_url' => $invoice['invoice_url'],
                'expiry_date' => $invoice['expiry_date'],
            ];

        } catch (\\Exception $e) {
            Log::error('Xendit Create Invoice Error', [
                'order_id' => $order->id,
                'message' => $e->getMessage(),
            ]);

            throw $e;
        }
    }

    /**
     * Format items untuk Xendit
     */
    private function formatItems(Order $order): array
    {
        return $order->items->map(function ($item) {
            return [
                'name' => $item->product_name,
                'quantity' => $item->quantity,
                'price' => (int) $item->price,
            ];
        })->toArray();
    }

    /**
     * Format phone number ke format Indonesia
     */
    private function formatPhoneNumber(?string $phone): ?string
    {
        if (!$phone) {
            return null;
        }

        // Hapus karakter non-numeric
        $phone = preg_replace('/[^0-9]/', '', $phone);

        // Convert 08xxx ke +628xxx
        if (str_starts_with($phone, '0')) {
            $phone = '+62' . substr($phone, 1);
        } elseif (str_starts_with($phone, '62')) {
            $phone = '+' . $phone;
        } elseif (!str_starts_with($phone, '+')) {
            $phone = '+62' . $phone;
        }

        return $phone;
    }

    /**
     * Handle webhook dari Xendit
     */
    public function handleWebhook(array $payload): Order
    {
        $externalId = $payload['external_id'];
        $status = $payload['status'];
        $paymentMethod = $payload['payment_method'] ?? null;
        $paidAt = $payload['paid_at'] ?? null;

        $order = Order::where('order_number', $externalId)->firstOrFail();

        Log::info('Xendit Webhook Received', [
            'order_number' => $externalId,
            'status' => $status,
            'payment_method' => $paymentMethod,
        ]);

        match ($status) {
            'PAID', 'SETTLED' => $this->markAsPaid($order, $paymentMethod, $paidAt),
            'EXPIRED' => $order->update(['payment_status' => 'expired']),
            'PENDING' => $order->update(['payment_status' => 'pending']),
            default => Log::warning('Xendit: Unknown status', ['status' => $status]),
        };

        return $order->fresh();
    }

    /**
     * Mark order as paid
     */
    private function markAsPaid(Order $order, ?string $paymentMethod, ?string $paidAt): void
    {
        $order->update([
            'payment_status' => 'paid',
            'payment_method' => $paymentMethod,
            'paid_at' => $paidAt ? \\Carbon\\Carbon::parse($paidAt) : now(),
        ]);

        // TODO: Trigger event
        // event(new OrderPaid($order));
    }

    /**
     * Verify webhook token
     */
    public function verifyWebhookToken(string $token): bool
    {
        return $token === config('xendit.webhook_token');
    }

    /**
     * Get invoice detail
     */
    public function getInvoice(string $invoiceId): array
    {
        return $this->invoiceApi->getInvoiceById($invoiceId);
    }
}

Bonus: Disbursement Service

Ini fitur unik Xendit yang nggak ada di Midtrans — kirim uang ke rekening lain:

// app/Services/XenditDisbursementService.php
<?php

namespace App\\Services;

use Xendit\\Payout\\PayoutApi;
use Xendit\\Payout\\CreatePayoutRequest;
use Illuminate\\Support\\Facades\\Log;

class XenditDisbursementService
{
    private PayoutApi $payoutApi;

    public function __construct()
    {
        $this->payoutApi = new PayoutApi();
    }

    /**
     * Kirim uang ke rekening bank
     *
     * @param string $bankCode ID_BCA, ID_BNI, ID_BRI, ID_MANDIRI, dll
     * @param string $accountNumber Nomor rekening tujuan
     * @param string $accountName Nama pemilik rekening
     * @param int $amount Jumlah dalam Rupiah
     * @param string $description Deskripsi transfer
     */
    public function sendMoney(
        string $bankCode,
        string $accountNumber,
        string $accountName,
        int $amount,
        string $description = ''
    ): array {
        $referenceId = 'PAYOUT-' . now()->format('YmdHis') . '-' . uniqid();

        $request = new CreatePayoutRequest([
            'reference_id' => $referenceId,
            'channel_code' => $bankCode,
            'channel_properties' => [
                'account_number' => $accountNumber,
                'account_holder_name' => $accountName,
            ],
            'amount' => $amount,
            'currency' => 'IDR',
            'description' => $description ?: 'Disbursement',
        ]);

        try {
            $payout = $this->payoutApi->createPayout($request);

            Log::info('Xendit Disbursement Created', [
                'reference_id' => $referenceId,
                'amount' => $amount,
                'bank' => $bankCode,
            ]);

            return [
                'reference_id' => $referenceId,
                'payout_id' => $payout['id'],
                'status' => $payout['status'],
                'amount' => $amount,
            ];

        } catch (\\Exception $e) {
            Log::error('Xendit Disbursement Error', [
                'message' => $e->getMessage(),
                'bank' => $bankCode,
                'amount' => $amount,
            ]);

            throw $e;
        }
    }

    /**
     * Batch disbursement - kirim ke banyak rekening sekaligus
     */
    public function batchSendMoney(array $recipients): array
    {
        $results = [];

        foreach ($recipients as $recipient) {
            try {
                $result = $this->sendMoney(
                    $recipient['bank_code'],
                    $recipient['account_number'],
                    $recipient['account_name'],
                    $recipient['amount'],
                    $recipient['description'] ?? ''
                );
                $results[] = array_merge($result, ['success' => true]);
            } catch (\\Exception $e) {
                $results[] = [
                    'success' => false,
                    'error' => $e->getMessage(),
                    'recipient' => $recipient,
                ];
            }
        }

        return $results;
    }
}

Contoh penggunaan disbursement:

// Di controller atau job
$disbursement = app(XenditDisbursementService::class);

// Bayar seller setelah order complete
$result = $disbursement->sendMoney(
    bankCode: 'ID_BCA',
    accountNumber: '1234567890',
    accountName: 'TOKO BUDI JAYA',
    amount: 450000,
    description: 'Pembayaran Order #INV-20251221-ABC12'
);

// Batch payout untuk payroll
$results = $disbursement->batchSendMoney([
    ['bank_code' => 'ID_BCA', 'account_number' => '111', 'account_name' => 'JOHN', 'amount' => 5000000],
    ['bank_code' => 'ID_BNI', 'account_number' => '222', 'account_name' => 'JANE', 'amount' => 6000000],
    ['bank_code' => 'ID_BRI', 'account_number' => '333', 'account_name' => 'BOB', 'amount' => 4500000],
]);

Controller

// app/Http/Controllers/XenditCheckoutController.php
<?php

namespace App\\Http\\Controllers;

use App\\Models\\Order;
use App\\Services\\XenditService;
use Illuminate\\Support\\Facades\\Log;

class XenditCheckoutController extends Controller
{
    public function __construct(
        private XenditService $xenditService
    ) {}

    /**
     * Initiate payment - redirect ke Xendit
     */
    public function pay(Order $order)
    {
        if ($order->user_id !== auth()->id()) {
            abort(403);
        }

        if (!$order->isPayable()) {
            return redirect()
                ->route('orders.show', $order)
                ->with('error', 'Order ini sudah tidak bisa dibayar.');
        }

        try {
            $invoice = $this->xenditService->createInvoice($order);

            // Redirect langsung ke halaman payment Xendit
            return redirect($invoice['invoice_url']);

        } catch (\\Exception $e) {
            Log::error('Xendit payment failed', [
                'order_id' => $order->id,
                'error' => $e->getMessage(),
            ]);

            return back()->with('error', 'Gagal memproses pembayaran. Silakan coba lagi.');
        }
    }

    /**
     * Success callback dari Xendit
     */
    public function success(Order $order)
    {
        $order->refresh();

        return view('xendit.success', [
            'order' => $order,
        ]);
    }

    /**
     * Failed callback dari Xendit
     */
    public function failed(Order $order)
    {
        return view('xendit.failed', [
            'order' => $order,
        ]);
    }
}

Webhook Controller

// app/Http/Controllers/Webhooks/XenditWebhookController.php
<?php

namespace App\\Http\\Controllers\\Webhooks;

use App\\Http\\Controllers\\Controller;
use App\\Services\\XenditService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Log;

class XenditWebhookController extends Controller
{
    public function __construct(
        private XenditService $xenditService
    ) {}

    /**
     * Handle incoming webhook dari Xendit
     */
    public function handle(Request $request)
    {
        $payload = $request->all();
        $callbackToken = $request->header('x-callback-token');

        Log::info('Xendit Webhook Received', [
            'payload' => $payload,
            'has_token' => !empty($callbackToken),
        ]);

        // Verify webhook token
        if (!$this->xenditService->verifyWebhookToken($callbackToken ?? '')) {
            Log::warning('Xendit Webhook: Invalid token');
            return response()->json(['error' => 'Invalid callback token'], 401);
        }

        try {
            $order = $this->xenditService->handleWebhook($payload);

            return response()->json([
                'status' => 'ok',
                'order_id' => $order->order_number,
                'payment_status' => $order->payment_status,
            ]);

        } catch (\\Exception $e) {
            Log::error('Xendit Webhook Error', [
                'message' => $e->getMessage(),
                'payload' => $payload,
            ]);

            return response()->json(['error' => 'Processing failed'], 500);
        }
    }
}

Routes

// routes/web.php
use App\\Http\\Controllers\\XenditCheckoutController;

Route::middleware('auth')->group(function () {
    Route::get('/xendit/{order}/pay', [XenditCheckoutController::class, 'pay'])
        ->name('xendit.pay');
    Route::get('/xendit/{order}/success', [XenditCheckoutController::class, 'success'])
        ->name('xendit.success');
    Route::get('/xendit/{order}/failed', [XenditCheckoutController::class, 'failed'])
        ->name('xendit.failed');
});

// routes/api.php
use App\\Http\\Controllers\\Webhooks\\XenditWebhookController;

Route::post('/webhooks/xendit', [XenditWebhookController::class, 'handle'])
    ->name('webhooks.xendit');

Set Webhook di Dashboard Xendit

  1. Login ke dashboard.xendit.co
  2. Settings → Webhooks
  3. Add Webhook URL: https://yourdomain.com/api/webhooks/xendit
  4. Copy Webhook Verification Token → paste ke .env sebagai XENDIT_WEBHOOK_TOKEN

💡 Mini Tips: Xendit pakai header x-callback-token untuk verify webhook, sedangkan Midtrans pakai signature di body. Approach Xendit lebih simple — cukup compare string, tidak perlu hitung hash.


Bagian 6: Perbandingan Code & Developer Experience

Sekarang kita sudah implement keduanya. Mari bandingkan dari perspektif developer.

Side-by-Side: Create Payment

Midtrans (Snap):

// 1. Create token
$params = [
    'transaction_details' => ['order_id' => $orderId, 'gross_amount' => $amount],
    'customer_details' => ['first_name' => $name, 'email' => $email],
];
$snapToken = Snap::getSnapToken($params);

// 2. Di frontend, panggil popup
snap.pay(snapToken, { onSuccess, onPending, onError });

Xendit (Invoice):

// 1. Create invoice
$request = new CreateInvoiceRequest([
    'external_id' => $orderId,
    'amount' => $amount,
    'customer' => ['given_names' => $name, 'email' => $email],
    'success_redirect_url' => $successUrl,
]);
$invoice = $invoiceApi->createInvoice($request);

// 2. Redirect user
return redirect($invoice['invoice_url']);

Verdict: Xendit sedikit lebih simple — tidak perlu handle JavaScript popup.

Side-by-Side: Webhook Verification

Midtrans:

$signatureKey = hash('sha512',
    $payload['order_id'] .
    $payload['status_code'] .
    $payload['gross_amount'] .
    $serverKey
);
$isValid = $payload['signature_key'] === $signatureKey;

Xendit:

$callbackToken = $request->header('x-callback-token');
$isValid = $callbackToken === config('xendit.webhook_token');

Verdict: Xendit lebih simple — just string comparison.

Complexity Comparison

MetricMidtransXendit
Lines of Code (Service)~120~100
Lines of Code (Controller)~50~40
Frontend JavaScriptRequired (Snap)Not required
Webhook ComplexityMedium (hash)Low (token)
Total Setup Time~2-3 hours~1-2 hours

Developer Experience Rating

AspectMidtransXenditNotes
Documentation⭐⭐⭐⭐⭐⭐⭐⭐⭐Xendit docs lebih modern
SDK Quality⭐⭐⭐⭐⭐⭐⭐⭐⭐Xendit SDK lebih clean
Error Messages⭐⭐⭐⭐⭐⭐⭐Xendit lebih descriptive
Sandbox Testing⭐⭐⭐⭐⭐⭐⭐⭐⭐Midtrans simulator lebih lengkap
Dashboard UX⭐⭐⭐⭐⭐⭐⭐⭐⭐Xendit lebih modern
Community⭐⭐⭐⭐⭐⭐⭐⭐⭐Midtrans lebih banyak tutorial Indo
Support Response⭐⭐⭐⭐⭐⭐⭐⭐⭐Both responsive

Error Handling Comparison

Midtrans Error Response:

{
    "status_code": "400",
    "status_message": "transaction_details.order_id sudah digunakan"
}

Xendit Error Response:

{
    "error_code": "DUPLICATE_EXTERNAL_ID",
    "message": "external_id has been used before. Use a unique external_id and try again."
}

Xendit error messages lebih jelas dan actionable.

Testing Locally dengan ngrok

Kedua payment gateway butuh public URL untuk webhook. Untuk local development:

# Install ngrok
brew install ngrok  # macOS
# atau download dari ngrok.com

# Expose Laravel
ngrok http 8000

# Output:
# Forwarding  <https://abc123.ngrok-free.app> -> <http://localhost:8000>

Set webhook URL di dashboard:

  • Midtrans: https://abc123.ngrok-free.app/api/webhooks/midtrans
  • Xendit: https://abc123.ngrok-free.app/api/webhooks/xendit

Pro tip: Ngrok free tier URL berubah setiap restart. Untuk development serius, pertimbangkan ngrok paid atau alternatives seperti Expose, Cloudflare Tunnel, atau LocalTunnel.

💡 Mini Tips: Saat development, selalu log semua webhook payload. Ini memudahkan debugging ketika ada status yang tidak ter-handle dengan benar.


Bagian 7: Kapan Pilih Mana? + Penutup

Setelah breakdown panjang lebar, saatnya kesimpulan praktis.

Decision Matrix

Kebutuhan BisnisPilihanAlasan
E-commerce standarMidtransGoPay support, settlement cepat
Marketplace dengan payout ke sellerXenditDisbursement feature
Food delivery appMidtransGoPay native (target market Gojek users)
SaaS subscriptionBothSama-sama support recurring
Platform regional (PH, VN)XenditMulti-country support
Fintech / P2P lendingXenditDisbursement + compliance
Kelas online / digital productMidtransSimple, GoPay, community support
Payroll systemXenditBatch disbursement

Quick Decision

                    ┌─────────────────────────┐
                    │   Butuh kirim uang      │
                    │   ke rekening lain?     │
                    └───────────┬─────────────┘
                                │
                    ┌───────────┴───────────┐
                    │                       │
                   YES                      NO
                    │                       │
                    ▼                       ▼
              ┌──────────┐         ┌─────────────────┐
              │  XENDIT  │         │ Target customer │
              └──────────┘         │ banyak pakai    │
                                   │ GoPay?          │
                                   └────────┬────────┘
                                            │
                                ┌───────────┴───────────┐
                                │                       │
                               YES                      NO
                                │                       │
                                ▼                       ▼
                          ┌──────────┐          ┌──────────────┐
                          │ MIDTRANS │          │ EITHER WORKS │
                          └──────────┘          │ (pick based  │
                                                │ on DX pref)  │
                                                └──────────────┘

Kombinasi Keduanya

Tidak ada rule yang bilang harus pilih satu. Banyak bisnis mature yang pakai kombinasi:

// Strategy Pattern untuk multiple gateway
interface PaymentGatewayInterface
{
    public function createPayment(Order $order): string;
    public function handleWebhook(array $payload): Order;
}

class MidtransGateway implements PaymentGatewayInterface
{
    public function createPayment(Order $order): string
    {
        // Return snap token atau redirect URL
    }

    public function handleWebhook(array $payload): Order
    {
        // Handle Midtrans notification
    }
}

class XenditGateway implements PaymentGatewayInterface
{
    public function createPayment(Order $order): string
    {
        // Return invoice URL
    }

    public function handleWebhook(array $payload): Order
    {
        // Handle Xendit notification
    }
}

// Usage
class PaymentService
{
    public function processPayment(Order $order, string $gateway = 'midtrans')
    {
        $handler = match($gateway) {
            'midtrans' => app(MidtransGateway::class),
            'xendit' => app(XenditGateway::class),
            default => throw new \\InvalidArgumentException("Unknown gateway: {$gateway}"),
        };

        return $handler->createPayment($order);
    }
}

Dengan pattern ini, kamu bisa:

  • Offer pilihan payment gateway ke user
  • A/B test conversion rate
  • Fallback ke gateway lain kalau satu down
  • Pakai Midtrans untuk terima bayaran, Xendit untuk disburse

Hal yang Sering Dilupakan

  1. Idempotency — Webhook bisa dikirim lebih dari sekali. Pastikan handle duplicate dengan check status sebelum update.
  2. Timeout handling — Set invoice/snap expiry yang reasonable (24 jam biasanya cukup).
  3. Logging — Log semua webhook untuk debugging. Ketika ada dispute, log adalah bukti.
  4. Error notification — Setup alert (Slack, email) untuk webhook errors.
  5. Reconciliation — Rutin compare data di dashboard gateway dengan database kamu.

Penutup

Kita sudah cover banyak ground:

✅ Perbandingan fitur dan pricing ✅ Setup Laravel untuk kedua gateway ✅ Implementasi lengkap Midtrans Snap ✅ Implementasi lengkap Xendit Invoice ✅ Bonus: Xendit Disbursement ✅ Code comparison dan DX rating ✅ Decision framework

My personal take:

Untuk projek BuildWithAngga dan sebagian besar client, saya default ke Midtrans karena:

  • Mayoritas user Indonesia pakai GoPay
  • Settlement lebih cepat
  • Community dan tutorial Bahasa Indonesia lebih banyak

Tapi untuk projek marketplace atau yang butuh bayar ke vendor/seller, Xendit adalah pilihan yang lebih logical karena fitur disbursement-nya.

Yang paling penting: jangan overthink. Pilih satu, implement, launch. Kamu bisa selalu iterate atau tambah gateway lain nanti ketika bisnis sudah grow.

Payment gateway adalah solved problem. Focus energy kamu ke hal yang lebih penting — building great product dan acquiring customers.


Resources

Untuk yang mau deep dive lebih lanjut:

Official Docs:

GitHub:

Learning: Untuk belajar Laravel dan payment integration dengan studi kasus real project, explore kelas-kelas di BuildWithAngga. Ada track lengkap dari basics sampai production-ready e-commerce.

Templates: Butuh template checkout page yang sudah polished? Check shaynakit.com — ada berbagai UI template yang tinggal integrate dengan code di artikel ini.


Semoga artikel ini membantu kamu decide dan implement payment gateway dengan lebih confident. Kalau ada pertanyaan atau pengalaman berbeda, feel free to share.

Happy coding! 🚀

💡 Mini Tips: Bookmark artikel ini. Payment gateway implementation adalah skill yang akan kamu pakai berulang kali di berbagai projek. Dan kalau stuck, re-read bagian yang relevan — biasanya solusinya sudah ada di sini.