Pembeli sudah bayar. Dashboard Midtrans bilang settlement, tapi di aplikasimu order itu masih pending. Kamu buka log, isinya kosong. Seakan-akan Midtrans tidak pernah mengirim apa pun ke servermu.

Tenang, biasanya notifikasinya sudah dikirim kok. FAQ Midtrans sendiri menyebut penyebab yang paling mungkin ada di handler Notification URL di sisi merchant: servermu membalas dengan status code selain 2xx, Midtrans mencoba lagi beberapa kali, lalu berhenti. Status code balasan itulah petunjuk paling jelas yang kamu punya.

Di artikel ini kita bahas cara Midtrans mengirim notifikasi, dua langkah untuk menemukan penyebabnya, satu jebakan di Laravel yang lolos dari test, dan jaring pengaman supaya order tetap sinkron walaupun notifikasinya tidak pernah sampai. Semua output di sini berasal dari menjalankan repo contohnya dengan Laravel 13, jadi kamu bisa mencobanya sendiri.

Cara Midtrans mengirim notifikasi

Setiap kali status transaksi berubah, Midtrans mengirim HTTP POST berisi JSON ke Payment Notification URL yang kamu isi di dashboard (Settings > Configuration). Dari dokumentasinya, ada beberapa aturan yang menentukan notifikasi itu “masuk” atau tidak:

  • URL-nya harus bisa dijangkau dari internet publik. Midtrans tidak bisa mengirim ke localhost, ke URL di balik VPN atau password, atau ke port yang tidak standar.
  • Midtrans tidak bisa menambahkan header. Kalau endpoint-mu meminta Basic Auth atau API key di header, notifikasinya akan ditolak.
  • Notifikasi dikirim dengan TLS paling tinggi versi 1.2. Server yang cuma menerima TLS 1.3 tidak akan kebagian.
  • Midtrans menunggu paling lama 15 detik, dan menyarankan kamu membalas dalam 5 detik.
  • Notifikasi baru dianggap berhasil kalau servermu membalas 2xx. Untuk status code lain, perlakuannya beda-beda:
Status code dari servermu Yang dilakukan Midtrans
2xx Dianggap berhasil, tidak ada retry
500 Retry 1 kali
503 Retry 4 kali
400, 404 Retry 2 kali
301, 302, 303 Tidak ada retry
307, 308 Mengikuti URL baru dengan POST dan body yang sama, maksimal 5 kali redirect
Kegagalan lain Retry 5 kali

Jeda antar-retry paling lama 2 menit, 10 menit, 30 menit, 1,5 jam, lalu 3,5 jam, ditambah pergeseran waktu acak di setiap percobaan. Kalau dijumlah (2 + 10 + 30 + 90 + 210 menit), semua retry sudah habis paling lama sekitar 5 jam 42 menit setelah percobaan pertama. Setelah itu Midtrans berhenti, dan order-mu tetap pending sampai ada yang sadar.

Langkah 1: cek riwayat notifikasi di dashboard

Buka Settings > Configuration > See History di dashboard Midtrans, lalu cari Order ID-nya. Di sana tercatat semua notifikasi HTTP dan email untuk order itu, lengkap dengan status pengirimannya. Pilih baris dengan TYPE HTTP, geser ke kanan, lalu klik Details untuk melihat URL tujuan dan body JSON yang dikirim.

Dari situ, kamu akan berada di salah satu dari dua kondisi ini:

  • Pengiriman gagal. Endpoint-mu menolak notifikasinya. Lanjut ke langkah 2.
  • Pengiriman berhasil, tapi order tidak berubah. Midtrans sudah menerima 2xx, jadi masalahnya ada di logika handler-mu. Misalnya ada status yang tidak dipetakan, order dicari dengan ID yang salah, atau exception yang ditangkap lalu tetap dibalas 200.

Cek juga URL di detail itu. Kalau transaksinya dibuat dengan header X-Override-Notification, notifikasi dikirim ke URL dari header tersebut, bukan ke URL di dashboard.

Pernah dapat email dari Midtrans yang bunyinya “We are having difficulty sending notification to the endpoint that you specified”? Lihat bagian Error detail di email itu. Biasanya status code-nya sudah disebut di sana.

Langkah 2: kirim ulang notifikasinya pakai curl

Salin body JSON dari Details tadi, lalu kirim ke Notification URL-mu dengan template dari FAQ Midtrans:

curl -i -X POST https://tokomu.example/midtrans/notification \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d 'TEMPEL_JSON_NOTIFIKASI_DI_SINI'

Body asli itu ditandatangani Midtrans, jadi handler yang benar akan benar-benar memprosesnya. Pastikan handler-mu idempoten dulu (kita bahas di bawah), atau coba di staging. Satu lagi: request dari laptopmu tidak persis sama dengan request dari Midtrans. Firewall atau WAF bisa saja memperlakukan IP Midtrans secara berbeda.

Lihat status code di baris pertama respons, lalu cocokkan dengan tabel ini:

Status code Penyebab yang umum Perbaikan
301, 302, 303 Redirect: http ke https, domain tanpa www ke www, atau garis miring yang ditambahkan di akhir URL Isi dashboard dengan URL final. Coba buka URL-nya di browser: kalau alamatnya berubah, berarti ada redirect. Redirect ini tidak di-retry, dan bisa mengubah POST menjadi GET sehingga body-nya hilang.
401, 403 Endpoint meminta autentikasi, atau WAF (Cloudflare, ModSecurity, dan sejenisnya) atau firewall memblokir Midtrans Izinkan request tanpa header tambahan dan izinkan IP notifikasi Midtrans. Untuk memastikan notifikasinya asli, pakai signature, bukan autentikasi.
404 Path salah, route belum ter-deploy, atau handler membalas 404 karena order tidak ditemukan Cocokkan URL dengan route-mu, lalu cek order_id yang dikirim.
419 Laravel: route ada di routes/web.php dan kena pengecekan CSRF Lihat bagian berikutnya.
500 Exception di handler Baca log aplikasi. Ingat, Midtrans cuma mencoba ulang sekali.
Timeout atau tidak terjangkau Handler lebih dari 15 detik, server mati, URL localhost atau di balik VPN, port tidak standar, atau server cuma menerima TLS 1.3 Balas secepatnya dan pindahkan pekerjaan berat ke queue. Pastikan servermu bisa diakses publik dengan TLS 1.2.

Reproduksi: callback ditolak 419 di Laravel

Dari semua baris di tabel itu, 419 yang paling licin. Route di routes/web.php otomatis masuk middleware group web, yang menurut dokumentasi Laravel menyediakan session dan proteksi CSRF. Dan handler notifikasi gampang sekali berakhir di sana, karena di project Laravel 13 yang baru, routes/web.php adalah satu-satunya file route untuk HTTP.

// routes/web.php
Route::post('/midtrans/notification', MidtransNotificationController::class);

Di Laravel 13, proteksi CSRF dijalankan oleh middleware PreventRequestForgery. Middleware ini meloloskan request yang header Sec-Fetch-Site-nya menunjukkan asal yang sama, lalu memeriksa token CSRF. Masalahnya, header itu diisi oleh browser, dan Midtrans tidak tahu token CSRF-mu. Notifikasinya gagal di dua-duanya.

Begini jadinya saat repo contoh menerima notifikasi seperti yang dikirim Midtrans. Aplikasinya jalan dengan APP_DEBUG=false seperti di production, dan header Set-Cookie sengaja tidak saya tampilkan:

curl -i -X POST http://127.0.0.1:8000/midtrans/notification \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d @contoh/notifikasi-settlement.json
HTTP/1.1 419 unknown status
Host: 127.0.0.1:8000
Connection: close
X-Powered-By: PHP/8.4.3
Cache-Control: no-cache, private
Date: Fri, 16 Oct 2026 01:25:54 GMT
Content-Type: application/json

{
    "message": "CSRF token mismatch."
}

Setelah request itu, storage/logs/laravel.log tetap kosong, 0 byte. Controller-nya tidak pernah jalan, dan Laravel memang tidak mencatat error ini: TokenMismatchException dan HttpException ada di daftar $internalDontReport milik exception handler-nya (source). Jadi dari sisimu, notifikasinya benar-benar terlihat “tidak masuk”.

Dari sisi Midtrans, 419 tidak punya aturan khusus, jadi masuk “kegagalan lain”: dikirim ulang lima kali, kelimanya dibalas 419, lalu Midtrans menyerah.

Kenapa test-mu tetap hijau

Handler ini punya test, dan test-nya lolos:

public function test_settlement_marks_the_order_paid(): void
{
    $this->postJson('/midtrans/notification', $this->notification())->assertOk();

    $this->assertSame('paid', $this->order->fresh()->status);
}
   PASS  Tests\Feature\MidtransNotificationTest
  ✓ settlement marks the order paid                                      0.16s
  ✓ invalid signature is rejected                                        0.01s
  ✓ duplicate and late notifications change nothing                      0.01s

  Tests:    3 passed (9 assertions)
  Duration: 0.34s

Dokumentasi Laravel menjelaskan sebabnya: “the CSRF middleware is automatically disabled for all routes when running tests.” Di source-nya, PreventRequestForgery langsung meloloskan request kalau runningUnitTests() bernilai true (source). Artinya, test ini membuktikan logika controller-mu benar, tapi tidak membuktikan request dari Midtrans bisa sampai ke controller.

Dua request itu melewati jalur yang sama, lalu berpisah tepat di pengecekan CSRF:

Diagram alur. Notifikasi Midtrans dari production dan postJson() dari feature test sama-sama mengirim POST /midtrans/notification lewat routes/web.php dan middleware group web, lalu tiba di PreventRequestForgery. Di sana jalurnya berpisah. Notifikasi Midtrans, yang tidak membawa token CSRF, dibalas 419 CSRF token mismatch: controller tidak jalan dan laravel.log kosong. Request dari test dilewati saat test: controller jalan, order menjadi paid, dan hasilnya 200 OK, test hijau.

Test yang meniru request Midtrans

Supaya test-mu melihat apa yang dilihat Midtrans, nyalakan lagi pengecekan CSRF di setUp():

use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;

protected function setUp(): void
{
    parent::setUp();

    // Laravel skips the CSRF check while running tests, but Midtrans
    // can't send a CSRF token. Turn the check back on, as in production.
    $this->app->bind(PreventRequestForgery::class, fn ($app) => new class($app, $app['encrypter']) extends PreventRequestForgery
    {
        protected function runningUnitTests()
        {
            return false;
        }
    });

    // ...
}

Test yang sama sekarang merah, dengan 419 yang sama persis seperti yang diterima Midtrans:

   FAIL  Tests\Feature\MidtransNotificationTest
  ⨯ settlement marks the order paid                                      0.16s
  ⨯ invalid signature is rejected                                        0.01s
  ⨯ duplicate and late notifications change nothing                      0.01s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\MidtransNotificationTest > settlement marks the ord…
  Expected response status code [200] but received 419.
Failed asserting that 419 is identical to 200.

  at tests/Feature/MidtransNotificationTest.php:36

  [...]

  Tests:    3 failed (3 assertions)
  Duration: 0.39s

Cara ini bergantung pada method runningUnitTests(). Jadi setiap habis upgrade Laravel, pastikan test-nya masih merah tanpa perbaikan di bawah. Repo contoh menyimpan kondisi itu di tag 2-test-merah.

Perbaikannya

Midtrans tidak bisa mengirim token CSRF, jadi route notifikasi perlu dikeluarkan dari pengecekan itu. Dokumentasi Laravel memberi dua cara: taruh route-nya di luar group web, misalnya di routes/api.php (URL-nya otomatis berawalan /api), atau daftarkan URI-nya sebagai pengecualian di bootstrap/app.php. Saya pakai cara kedua di repo contoh, karena Notification URL di dashboard tidak perlu diganti:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    // Midtrans can't send a CSRF token. The controller checks
    // signature_key instead, so this exception is safe.
    $middleware->preventRequestForgery(except: [
        'midtrans/notification',
    ]);
})

Kalau kamu memilih routes/api.php, jangan lupa ganti Notification URL di dashboard ke URL yang baru.

Pengecualian ini baru aman kalau handler-mu memverifikasi signature_key. Midtrans menghitungnya dengan SHA512(order_id + status_code + gross_amount + ServerKey), dan Server Key cuma diketahui kamu dan Midtrans:

// An empty key would make the signature computable by anyone.
$serverKey = config('services.midtrans.server_key')
    ?: throw new RuntimeException('MIDTRANS_SERVER_KEY is not set.');

$expected = hash('sha512', $request->input('order_id')
    .$request->input('status_code')
    .$request->input('gross_amount')
    .$serverKey);

// Not sent by Midtrans, or the server key is from the other environment (sandbox vs production).
abort_unless(hash_equals($expected, (string) $request->input('signature_key')), 403);

Midtrans sendiri lebih menyarankan verifikasi ini daripada whitelist IP. Satu hal kecil soal signature: pakai gross_amount persis seperti string yang dikirim ("150000.00"). Kalau kamu ubah jadi angka dulu sebelum di-hash, hasilnya pasti beda.

Setelah perbaikan, test kembali hijau, kali ini dengan pengecekan CSRF yang menyala:

   PASS  Tests\Feature\MidtransNotificationTest
  ✓ settlement marks the order paid                                      0.16s
  ✓ invalid signature is rejected                                        0.01s
  ✓ duplicate and late notifications change nothing                      0.01s

  Tests:    3 passed (9 assertions)
  Duration: 0.34s

Curl yang sama sekarang dibalas 200:

HTTP/1.1 200 OK
Host: 127.0.0.1:8000
Connection: close
X-Powered-By: PHP/8.4.3
Cache-Control: no-cache, private
Date: Fri, 16 Oct 2026 01:26:44 GMT
Content-Type: text/html; charset=utf-8

OK

Dan log aplikasi akhirnya mencatat notifikasinya:

[2026-10-16 01:26:44] local.INFO: Midtrans notification {"order_id":"ORDER-1001","transaction_status":"settlement","status_code":"200"}

Proses notifikasi secara idempoten

Menurut Midtrans, pada kasus yang sangat jarang, notifikasi untuk event yang sama bisa terkirim lebih dari sekali, dan urutannya bisa terbalik (settlement datang sebelum pending). Retry juga berarti notifikasi yang sama bisa datang lagi kalau balasan pertamamu lewat dari 15 detik. Di repo contoh, semua itu ditangani dengan satu UPDATE bersyarat: hanya order yang masih pending yang boleh berubah.

// app/Models/Order.php
public function applyMidtransStatus(array $trx): bool
{
    $status = $trx['transaction_status'] ?? null;

    // The three fields Midtrans says to check for a successful payment.
    $paid = ($trx['status_code'] ?? null) === '200'
        && in_array($status, ['capture', 'settlement'], true)
        && ($trx['fraud_status'] ?? 'accept') === 'accept';

    $changes = match (true) {
        $paid => ['status' => 'paid', 'paid_at' => now()],
        in_array($status, ['deny', 'cancel', 'expire', 'failure'], true) => ['status' => 'failed'],
        default => null, // pending, or a status this shop doesn't act on
    };

    return $changes !== null && static::whereKey($this->id)
        ->where('status', 'pending')
        ->update($changes) === 1;
}

Notifikasi kedua, atau notifikasi pending yang telat datang, tidak menemukan baris pending lagi, jadi tidak mengubah apa-apa. Test duplicate and late notifications change nothing di repo memastikan hal ini.

Jaring pengaman: rekonsiliasi lewat Get Status API

Endpoint yang sudah benar pun belum menjamin semua notifikasi sampai. Midtrans menyebut notifikasi bisa terlambat atau gagal karena gangguan jaringan, downtime, atau masalah di pihak vendor. Jumlah retry-nya juga terbatas: kalau servermu mati lebih lama dari jadwal retry, notifikasi itu tidak akan datang lagi. Untuk sistem yang bergantung pada status pembayaran, Midtrans merekomendasikan Get Status API sebagai failover.

Repo contoh menjalankan command terjadwal yang menanyakan status setiap order yang masih pending setelah 15 menit. Kenapa 15 menit? Itu cukup untuk notifikasi pertama dan dua retry pertama, yang paling lama selesai dalam 12 menit.

// routes/console.php
Artisan::command('midtrans:reconcile', function () {
    Order::where('status', 'pending')
        ->where('created_at', '<', now()->subMinutes(15))
        ->each(function (Order $order) {
            $response = Http::withBasicAuth(config('services.midtrans.server_key'), '')
                ->acceptJson()
                ->get(config('services.midtrans.base_url')."/v2/{$order->order_id}/status");

            // 404: the buyer hasn't chosen a payment method in Snap yet.
            if ($response->json('status_code') === '404') {
                return;
            }

            $response->throw();

            if ($order->applyMidtransStatus($response->json())) {
                $this->info("{$order->order_id}: pending -> {$order->fresh()->status}");
            }
        });
})->purpose('Sync pending orders with the Midtrans Get Status API');

Schedule::command('midtrans:reconcile')->everyFiveMinutes()->withoutOverlapping();

Beberapa hal yang perlu kamu tahu, dari dokumentasi Midtrans dan Laravel:

  • Get Status API memakai Basic Auth: Server Key sebagai username, password kosong.
  • Untuk transaksi Snap, Get Status membalas 404 selama pembeli belum memilih metode pembayaran. Command ini melewatinya dan mengecek lagi di jadwal berikutnya.
  • Respons dari request backend-mu sendiri ke API Midtrans sudah bisa dipercaya, jadi tidak perlu verifikasi signature lagi.
  • Command ini memakai applyMidtransStatus() yang sama dengan handler notifikasi. Jadi kalau notifikasi dan rekonsiliasi datang hampir bersamaan, hasilnya tetap aman.
  • Scheduler Laravel baru jalan kalau server menjalankan schedule:run setiap menit lewat cron:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

Hasil semua test di repo, termasuk test rekonsiliasi dengan respons Midtrans yang dipalsukan lewat Http::fake():

   PASS  Tests\Feature\MidtransNotificationTest
  ✓ settlement marks the order paid                                      0.17s
  ✓ invalid signature is rejected                                        0.01s
  ✓ duplicate and late notifications change nothing                      0.01s

   PASS  Tests\Feature\MidtransReconcileTest
  ✓ order paid without a notification is synced                          0.04s
  ✓ unpaid snap transaction stays pending

  Tests:    5 passed (15 assertions)
  Duration: 0.43s

Checklist sebelum go-live

  • Notification URL di dashboard sudah URL final: dibuka di browser, alamatnya tidak berubah.
  • Endpoint menerima POST tanpa header tambahan: tidak ada autentikasi, pengecekan CSRF, atau aturan WAF yang menolak Midtrans.
  • Server bisa dijangkau dari internet di port 80 atau 443, dengan TLS 1.2.
  • signature_key diverifikasi dengan Server Key dari environment yang sama (sandbox atau production).
  • Handler membalas 2xx dalam waktu kurang dari 5 detik; pekerjaan berat dipindah ke queue.
  • Notifikasi diproses secara idempoten berdasarkan order_id, dan notifikasi yang telat datang tidak menimpa status yang lebih baru.
  • Ada test yang mengirim notifikasi lewat middleware yang sama dengan production.
  • Order yang terlalu lama pending direkonsiliasi lewat Get Status API.

Sumber

Semua sumber dicek pada 16 Oktober 2026.

Kode contoh lengkap di GitHub →