Pada integrasi payment gateway, callback atau payment notification hampir selalu bisa dikirim lebih dari sekali. Penyebabnya umum: server Anda lambat merespons, terjadi timeout jaringan, provider menjalankan retry otomatis, atau dua request masuk hampir bersamaan. Jika endpoint Anda tidak idempoten, satu transaksi yang sama bisa diproses dua kali: order berubah status dua kali, stok terpotong lagi, atau log audit menjadi membingungkan.

Solusi praktisnya adalah menerapkan idempotency key pada endpoint callback. Di CodeIgniter 4, pola yang aman biasanya terdiri dari: menentukan key yang stabil untuk satu event pembayaran, menyimpan key tersebut di database dengan unique constraint, memverifikasi apakah request duplikat atau konflik, lalu membungkus proses bisnis di dalam transaksi database. Dengan begitu, retry dari provider tidak membuat transaksi diproses ulang.

Mengapa payment callback perlu idempotency key?

Masalah utama pada callback pembayaran bukan hanya request ganda, tetapi request ganda yang datang pada waktu yang tidak terduga. Contohnya:

  • Provider mengirim callback, tetapi endpoint Anda merespons terlalu lambat sehingga provider menganggap gagal lalu retry.
  • Provider memang mendesain notifikasi sebagai at-least-once delivery, artinya satu event dapat dikirim berkali-kali sampai mendapat respons sukses.
  • Dua worker aplikasi memproses callback yang sama hampir bersamaan, memicu race condition.
  • Status order sudah menjadi paid, tetapi callback duplikat kembali mengeksekusi logika pengurangan stok atau pembuatan invoice.

Idempotency berarti beberapa request identik hanya menghasilkan satu efek akhir. Endpoint boleh menerima callback yang sama berkali-kali, tetapi perubahan state hanya terjadi sekali.

Desain kontrak request untuk callback yang idempoten

Memilih idempotency key yang tepat

Idempotency key harus mewakili satu event unik. Sumbernya bisa berbeda tergantung provider:

  • Notification ID / event ID dari provider: pilihan terbaik jika tersedia dan benar-benar unik per notifikasi.
  • Transaction reference eksternal: bisa dipakai jika satu callback selalu merepresentasikan satu transaksi final.
  • Hash dari kombinasi field penting: opsi cadangan jika provider tidak memberi event ID yang andal.

Hindari memakai field yang tidak stabil seperti timestamp lokal server atau seluruh raw body tanpa normalisasi, karena perubahan kecil pada urutan JSON bisa menghasilkan key berbeda untuk event yang sama.

Contoh kontrak request yang masuk:

{
  "event_id": "cb_20240614_000123",
  "order_id": "ORD-2024-0001",
  "transaction_id": "TRX-998877",
  "payment_status": "paid",
  "paid_amount": 150000,
  "signature": "..."
}

Pada contoh di atas, event_id cocok dijadikan idempotency key. Jika tidak ada, Anda bisa mempertimbangkan kombinasi seperti transaction_id + payment_status, tetapi pastikan kombinasi itu benar-benar merepresentasikan satu event unik dalam alur provider yang Anda gunakan.

Tambahkan fingerprint payload

Selain menyimpan key, simpan juga fingerprint payload misalnya hash SHA-256 dari body mentah request. Ini berguna untuk membedakan dua kondisi:

  • Key sama, payload sama: kemungkinan retry normal, aman dianggap duplikat.
  • Key sama, payload berbeda: indikasi konflik, bug provider, atau potensi masalah integritas data.

Dengan menyimpan key tanpa fingerprint, Anda tahu request pernah diproses. Dengan fingerprint, Anda juga tahu apakah isi request masih konsisten.

Skema database: unique constraint sebagai pagar utama

Proteksi di level aplikasi saja tidak cukup. Pada beban nyata, dua request bisa lolos validasi bersamaan sebelum salah satunya menulis data. Karena itu, unique constraint di database adalah komponen terpenting untuk menahan race condition.

Contoh migration CodeIgniter 4 untuk tabel penyimpanan idempotency callback:

<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class CreatePaymentCallbackIdempotencies extends Migration
{
    public function up()
    {
        $this->forge->addField([
            'id' => [
                'type'           => 'BIGINT',
                'unsigned'       => true,
                'auto_increment' => true,
            ],
            'idempotency_key' => [
                'type'       => 'VARCHAR',
                'constraint' => 191,
            ],
            'payload_hash' => [
                'type'       => 'CHAR',
                'constraint' => 64,
            ],
            'provider_name' => [
                'type'       => 'VARCHAR',
                'constraint' => 50,
                'null'       => true,
            ],
            'order_id' => [
                'type'       => 'VARCHAR',
                'constraint' => 100,
                'null'       => true,
            ],
            'status' => [
                'type'       => 'VARCHAR',
                'constraint' => 20,
                'default'    => 'processing',
            ],
            'response_code' => [
                'type'       => 'INT',
                'null'       => true,
            ],
            'processed_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
            'created_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
            'updated_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
        ]);

        $this->forge->addKey('id', true);
        $this->forge->addUniqueKey('idempotency_key');
        $this->forge->createTable('payment_callback_idempotencies');
    }

    public function down()
    {
        $this->forge->dropTable('payment_callback_idempotencies');
    }
}

Hal penting dari skema di atas:

  • idempotency_key diberi unique key agar database menolak insert duplikat.
  • payload_hash disimpan untuk mendeteksi konflik isi request.
  • status membantu observasi: apakah request sedang diproses, berhasil, atau gagal.
  • processed_at memudahkan audit dan debugging.

Jika Anda memiliki beberapa provider payment, pertimbangkan unique key gabungan seperti provider_name + idempotency_key agar key dari provider berbeda tidak saling bertabrakan.

Alur validasi callback yang aman

Urutan proses sangat berpengaruh. Alur yang umum dan aman adalah:

  1. Ambil raw body request.
  2. Verifikasi signature atau autentikasi callback terlebih dahulu.
  3. Ekstrak idempotency key dari payload atau header.
  4. Hitung fingerprint payload.
  5. Coba simpan record idempotency baru ke database.
  6. Jika insert berhasil, lanjutkan proses bisnis di dalam transaksi database.
  7. Jika insert gagal karena duplicate key, cek payload hash yang tersimpan.
  8. Jika hash sama, kembalikan respons sukses/aman tanpa memproses ulang.
  9. Jika hash berbeda, kembalikan respons konflik dan catat log error.

Mengapa signature diverifikasi lebih dulu? Karena Anda tidak ingin menyimpan request palsu ke tabel idempotency lalu menganggapnya request sah di percobaan berikutnya.

Implementasi CodeIgniter 4

Model untuk tabel idempotency

<?php

namespace App\Models;

use CodeIgniter\Model;

class PaymentCallbackIdempotencyModel extends Model
{
    protected $table            = 'payment_callback_idempotencies';
    protected $primaryKey       = 'id';
    protected $returnType       = 'array';
    protected $useTimestamps    = true;
    protected $allowedFields    = [
        'idempotency_key',
        'payload_hash',
        'provider_name',
        'order_id',
        'status',
        'response_code',
        'processed_at',
    ];
}

Service untuk menangani idempotency key

Contoh service berikut memisahkan logika idempotency dari controller agar lebih mudah diuji dan digunakan ulang.

<?php

namespace App\Services;

use App\Models\PaymentCallbackIdempotencyModel;
use CodeIgniter\Database\Exceptions\DatabaseException;

class PaymentCallbackIdempotencyService
{
    public function __construct(
        protected PaymentCallbackIdempotencyModel $model = new PaymentCallbackIdempotencyModel()
    ) {
    }

    public function makePayloadHash(string $rawBody): string
    {
        return hash('sha256', $rawBody);
    }

    public function reserve(string $key, string $payloadHash, ?string $providerName = null, ?string $orderId = null): array
    {
        $data = [
            'idempotency_key' => $key,
            'payload_hash'    => $payloadHash,
            'provider_name'   => $providerName,
            'order_id'        => $orderId,
            'status'          => 'processing',
        ];

        try {
            $this->model->insert($data, false);

            return [
                'result' => 'reserved',
                'record' => $this->model->where('idempotency_key', $key)->first(),
            ];
        } catch (\Throwable $e) {
            $existing = $this->model->where('idempotency_key', $key)->first();

            if (! $existing) {
                throw $e;
            }

            if (hash_equals($existing['payload_hash'], $payloadHash)) {
                return [
                    'result' => 'duplicate_same_payload',
                    'record' => $existing,
                ];
            }

            return [
                'result' => 'duplicate_different_payload',
                'record' => $existing,
            ];
        }
    }

    public function markProcessed(string $key, int $responseCode = 200): void
    {
        $this->model
            ->where('idempotency_key', $key)
            ->set([
                'status'        => 'processed',
                'response_code' => $responseCode,
                'processed_at'  => date('Y-m-d H:i:s'),
            ])
            ->update();
    }

    public function markFailed(string $key, int $responseCode = 500): void
    {
        $this->model
            ->where('idempotency_key', $key)
            ->set([
                'status'        => 'failed',
                'response_code' => $responseCode,
            ])
            ->update();
    }
}

Catatan penting: pada contoh ini, penanganan duplikasi bergantung pada insert ke tabel yang memiliki unique constraint. Ini lebih aman dibanding pola check then insert murni karena race condition tetap bisa lolos pada pola tersebut.

Contoh controller endpoint payment callback

<?php

namespace App\Controllers;

use App\Models\OrderModel;
use App\Services\PaymentCallbackIdempotencyService;
use CodeIgniter\HTTP\ResponseInterface;

class PaymentCallbackController extends BaseController
{
    public function notify(): ResponseInterface
    {
        $rawBody = $this->request->getBody();
        $payload = json_decode($rawBody, true);

        if (! is_array($payload)) {
            return $this->response->setStatusCode(400)->setJSON([
                'message' => 'Invalid JSON payload',
            ]);
        }

        if (! $this->isValidSignature($rawBody, $payload['signature'] ?? '')) {
            log_message('warning', 'Invalid callback signature');

            return $this->response->setStatusCode(401)->setJSON([
                'message' => 'Invalid signature',
            ]);
        }

        $idempotencyKey = $payload['event_id'] ?? null;
        if (! $idempotencyKey) {
            return $this->response->setStatusCode(422)->setJSON([
                'message' => 'Missing event_id',
            ]);
        }

        $service = new PaymentCallbackIdempotencyService();
        $payloadHash = $service->makePayloadHash($rawBody);

        $reserve = $service->reserve(
            $idempotencyKey,
            $payloadHash,
            'example-provider',
            $payload['order_id'] ?? null
        );

        if ($reserve['result'] === 'duplicate_same_payload') {
            log_message('info', 'Duplicate callback ignored. key={key}', ['key' => $idempotencyKey]);

            return $this->response->setStatusCode(200)->setJSON([
                'message' => 'Already processed',
            ]);
        }

        if ($reserve['result'] === 'duplicate_different_payload') {
            log_message('error', 'Idempotency conflict. key={key}', ['key' => $idempotencyKey]);

            return $this->response->setStatusCode(409)->setJSON([
                'message' => 'Idempotency conflict',
            ]);
        }

        $db = db_connect();
        $orderModel = new OrderModel();

        try {
            $db->transBegin();

            $order = $orderModel->where('order_id', $payload['order_id'] ?? '')->first();
            if (! $order) {
                throw new \RuntimeException('Order not found');
            }

            if ($order['payment_status'] !== 'paid' && ($payload['payment_status'] ?? null) === 'paid') {
                $orderModel->update($order['id'], [
                    'payment_status' => 'paid',
                    'paid_at'        => date('Y-m-d H:i:s'),
                ]);

                // Tempatkan efek samping penting lain di sini,
                // misalnya penulisan payment log atau pembuatan invoice.
            }

            if ($db->transStatus() === false) {
                throw new \RuntimeException('Transaction failed');
            }

            $db->transCommit();
            $service->markProcessed($idempotencyKey, 200);

            return $this->response->setStatusCode(200)->setJSON([
                'message' => 'OK',
            ]);
        } catch (\Throwable $e) {
            if ($db->transStatus()) {
                $db->transRollback();
            }

            $service->markFailed($idempotencyKey, 500);
            log_message('error', 'Payment callback failed. key={key}, error={error}', [
                'key'   => $idempotencyKey,
                'error' => $e->getMessage(),
            ]);

            return $this->response->setStatusCode(500)->setJSON([
                'message' => 'Internal error',
            ]);
        }
    }

    private function isValidSignature(string $rawBody, string $signature): bool
    {
        // Implementasikan verifikasi sesuai spesifikasi provider.
        // Contoh: hash_hmac('sha256', $rawBody, $sharedSecret)
        return $signature !== '';
    }
}

Pola transaksi database yang perlu dijaga

Ada dua lapisan proteksi yang bekerja bersama:

  • Lapisan idempotency: mencegah event callback yang sama diproses lebih dari sekali.
  • Lapisan state bisnis: memastikan perubahan order sendiri juga aman, misalnya tidak mengubah order yang sudah paid menjadi paid lagi dengan efek samping tambahan.

Karena itu, walaupun idempotency key sudah ada, logika update order tetap harus defensif. Pada contoh di atas, update hanya dijalankan jika status order belum paid. Ini penting karena pada sistem nyata, perubahan status bisa datang dari jalur lain, misalnya panel admin, job rekonsiliasi, atau callback provider lain.

Kapan record idempotency dibuat?

Record idempotency sebaiknya dibuat sebelum proses bisnis dijalankan. Tujuannya agar request paralel kedua langsung tertahan oleh unique constraint. Jika record baru dibuat setelah order berhasil diupdate, dua request yang datang bersamaan masih bisa sama-sama mengeksekusi logika bisnis.

Bagaimana jika proses bisnis gagal?

Ini trade-off penting. Jika record idempotency sudah dibuat tetapi proses bisnis gagal, request berikutnya tidak boleh otomatis dianggap sukses. Karena itu, status record perlu dibedakan, misalnya processing, processed, dan failed. Saat menerima request duplikat, Anda dapat memutuskan respons berdasarkan status terakhir:

  • processed: aman balas sukses tanpa proses ulang.
  • processing: bisa balas sukses ringan atau status khusus, tergantung kontrak provider dan durasi proses Anda.
  • failed: perlu keputusan desain. Bisa diizinkan untuk reprocess secara manual, atau tetap diblok lalu diinvestigasi.

Untuk callback payment, banyak tim memilih memproses sesingkat mungkin: validasi, update status inti, catat log, balas 200. Pekerjaan berat seperti pengiriman email atau sinkronisasi analitik dipindahkan ke queue agar timeout lebih kecil.

Edge case yang wajib ditangani

1. Payload sama dengan key sama

Ini kasus retry normal. Respons aman adalah mengembalikan 200 OK atau respons sukses yang disepakati provider, tanpa memproses ulang. Tujuannya menghentikan retry lanjutan.

2. Key sama dengan payload berbeda

Ini bukan duplikat normal. Artinya dua request mengklaim sebagai event yang sama tetapi isi datanya berbeda. Kemungkinan penyebabnya:

  • Provider mengirim data inkonsisten.
  • Integrasi Anda salah menentukan key.
  • Ada manipulasi request atau replay yang tidak valid.

Pada kasus ini, jangan memproses diam-diam. Balas 409 Conflict atau respons error yang jelas, lalu catat log lengkap untuk investigasi.

3. Order sudah paid sebelum callback masuk

Bisa terjadi karena callback lain lebih dulu berhasil, atau status sudah direkonsiliasi dari proses lain. Endpoint tetap sebaiknya balas sukses, tetapi tidak menjalankan efek samping kedua kali.

4. Callback pertama timeout di sisi provider, padahal proses lokal sukses

Ini kasus yang sangat umum. Provider akan retry, dan idempotency key memastikan retry berikutnya hanya menghasilkan respons sukses tanpa mengubah state lagi.

5. Request paralel masuk hampir bersamaan

Inilah alasan unique constraint di database tidak boleh dihilangkan. Tanpa itu, dua proses bisa sama-sama melihat “belum ada record” lalu sama-sama mengubah order.

Observability dasar: logging dan debugging

Idempotency tidak cukup jika sulit diinvestigasi saat ada insiden. Tambahkan observability minimal berikut:

  • Log key penting: idempotency key, order_id, provider_name, payment_status.
  • Log hasil reserve: reserved, duplicate_same_payload, duplicate_different_payload.
  • Simpan payload hash, bukan selalu payload mentah, untuk menjaga privasi dan ukuran data.
  • Catat response code dan processed_at di tabel idempotency.
  • Korelasikan log dengan request ID jika sistem Anda sudah memakai correlation ID.

Jika perlu menyimpan payload mentah untuk audit, pastikan data sensitif seperti signature, nomor kartu, atau field rahasia tidak tercatat sembarangan di log aplikasi.

Tip debugging: saat menemukan order ganda atau callback aneh, cek lebih dulu apakah masalah berasal dari duplikasi callback atau dari logika bisnis yang tetap menjalankan efek samping meski status order sudah final.

Kesalahan umum saat menerapkan idempotency key

  • Hanya mengecek di memory atau cache lokal proses. Ini tidak aman untuk beberapa instance aplikasi.
  • Check then insert tanpa unique constraint. Ini rawan race condition.
  • Memakai key yang tidak benar-benar unik, misalnya hanya order_id padahal satu order bisa menerima beberapa event status.
  • Tidak membedakan payload sama dan payload berbeda untuk key yang sama.
  • Memproses pekerjaan berat di request callback hingga rawan timeout dan memicu retry tambahan.
  • Tidak membuat log yang cukup, sehingga sulit membuktikan apakah request duplikat benar-benar terjadi.

Rekomendasi implementasi praktis

Jika Anda ingin pola yang sederhana tetapi aman di CodeIgniter 4, gunakan pendekatan berikut:

  1. Verifikasi signature callback.
  2. Ambil event ID provider sebagai idempotency key.
  3. Hash raw body untuk payload fingerprint.
  4. Insert record idempotency ke tabel dengan unique constraint.
  5. Jika duplikat dengan hash sama, balas 200 tanpa proses ulang.
  6. Jika duplikat dengan hash berbeda, balas 409 dan log insiden.
  7. Jalankan update order di dalam transaksi database.
  8. Pastikan logika bisnis hanya mengubah state jika memang belum final.
  9. Balas sukses secepat mungkin, pindahkan efek samping berat ke queue bila perlu.

Dengan pola ini, endpoint payment callback Anda akan lebih tahan terhadap retry provider, timeout, dan race condition. Idempotency key bukan sekadar tambahan teknis, tetapi mekanisme inti untuk menjaga konsistensi order dan pembayaran pada sistem produksi.