Laravel API Idempotency Key digunakan untuk memastikan satu intent bisnis yang sama—misalnya membuat order—hanya diproses sekali, walaupun client mengirim ulang request karena timeout, koneksi putus, atau respons sebelumnya tidak sempat diterima. Ini penting pada endpoint seperti create order, pembayaran, top-up, dan pembuatan invoice.

Solusi ini bukan sekadar duplicate check biasa. Idempotency key bekerja pada level kontrak API: client mengirim kunci unik untuk satu operasi, server menyimpan fingerprint request dan hasil responsnya, lalu mengembalikan hasil yang sama saat request identik diulang. Dengan cara ini, retry menjadi aman tanpa membuat order ganda.

Kapan Idempotency Key Dibutuhkan

Gunakan idempotency key pada endpoint yang:

  • Melakukan operasi tulis yang tidak aman jika dieksekusi dua kali.
  • Bisa menerima retry otomatis dari mobile app, frontend, API gateway, atau job worker.
  • Memiliki efek bisnis penting, seperti order, pembayaran, klaim voucher, atau pendaftaran berbayar.

Untuk endpoint baca seperti GET, mekanisme ini biasanya tidak diperlukan karena secara semantik sudah idempoten.

Idempoten vs Duplicate Check Biasa

Duplicate check biasa

Pola yang sering dipakai adalah mengecek apakah record dengan kombinasi tertentu sudah ada, misalnya berdasarkan user_id dan product_id. Ini berguna, tetapi tidak selalu cukup.

  • Tidak selalu mewakili satu intent request.
  • Sulit membedakan retry sah dengan request baru yang kebetulan mirip.
  • Tidak menyimpan respons awal, sehingga client bisa menerima hasil berbeda pada retry.
  • Rentan race condition jika dua request masuk hampir bersamaan.

Idempotency key

Dengan idempotency key, client mengatakan: "request ini adalah operasi yang sama dengan request sebelumnya bila key-nya sama". Server lalu:

  1. Menyimpan Idempotency-Key.
  2. Menyimpan fingerprint payload yang konsisten.
  3. Menyimpan status proses: sedang diproses, selesai, atau gagal.
  4. Menyimpan response akhir untuk dikembalikan pada retry identik.

Ini membuat sistem lebih deterministik dan aman terhadap retry.

Desain Kontrak API

Header yang dipakai

Pola yang umum dan mudah dipahami adalah header berikut:

POST /api/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 7c7ff0d3-8b27-4f70-8a8e-8b7c2e75f2bb
Authorization: Bearer <token>

{
  "customer_id": 123,
  "items": [
    {"sku": "SKU-001", "qty": 2, "price": 15000},
    {"sku": "SKU-002", "qty": 1, "price": 30000}
  ],
  "shipping_address": "Jl. Melati No. 10"
}

Praktiknya:

  • Client harus membuat key unik untuk satu aksi bisnis.
  • Jika request yang sama perlu di-retry, client harus mengirim key yang sama.
  • Jika itu order baru, client harus memakai key baru.

Format Idempotency-Key

Server tidak perlu terlalu ketat soal format selama panjangnya dibatasi dan aman disimpan. Namun, format seperti UUID atau string acak panjang biasanya paling praktis.

Validasi minimum yang masuk akal:

  • Tidak kosong.
  • Panjang dibatasi, misalnya maksimal 255 karakter.
  • Hanya diterima pada endpoint yang memang mendukung idempotensi.

Status respons yang disarankan

  • 201 Created: request pertama berhasil membuat order.
  • 200 atau 201 dengan body yang sama: retry dengan key dan payload identik, respons diambil dari cache/penyimpanan idempotensi.
  • 409 Conflict: key sama tetapi payload berbeda, atau ada konflik status proses.
  • 422 Unprocessable Entity: payload tidak valid.

Yang paling penting bukan kode status tertentu, melainkan kontraknya konsisten dan terdokumentasi dengan jelas untuk client.

Desain Penyimpanan Idempotency

Data apa yang perlu disimpan

Minimal simpan:

  • idempotency_key
  • user_id atau identitas pemilik request
  • request_fingerprint
  • status seperti processing, completed, failed
  • response_code
  • response_body
  • resource_type dan resource_id bila perlu
  • locked_until atau metadata waktu proses
  • expires_at untuk TTL

Kenapa fingerprint perlu disimpan

Fingerprint mencegah penyalahgunaan key yang sama untuk payload berbeda. Misalnya client pertama kali mengirim order total Rp60.000, lalu retry dengan key sama tetapi item berbeda. Itu bukan retry sah dan harus ditolak.

Fingerprint biasanya dibuat dari payload yang sudah dinormalisasi, misalnya JSON yang diurutkan secara konsisten lalu di-hash. Tujuannya agar request identik menghasilkan hash yang sama.

Contoh skema tabel

Schema::create('idempotency_keys', function ($table) {
    $table->id();
    $table->unsignedBigInteger('user_id')->nullable();
    $table->string('idempotency_key', 255);
    $table->string('request_fingerprint', 64);
    $table->string('status', 20); // processing, completed, failed
    $table->unsignedSmallInteger('response_code')->nullable();
    $table->longText('response_body')->nullable();
    $table->string('resource_type', 50)->nullable();
    $table->unsignedBigInteger('resource_id')->nullable();
    $table->timestamp('locked_until')->nullable();
    $table->timestamp('expires_at')->nullable();
    $table->timestamps();

    $table->unique(['user_id', 'idempotency_key']);
    $table->index(['expires_at']);
});

Catatan penting: gabungkan key dengan identitas client atau user. Jangan hanya unik global jika sistem Anda multi-tenant atau banyak user, karena dua user berbeda bisa saja kebetulan memakai key yang sama.

Alur Request yang Aman

Alur normal

  1. Client mengirim POST /orders dengan Idempotency-Key.
  2. Server memvalidasi payload.
  3. Server membuat fingerprint dari payload yang sudah dinormalisasi.
  4. Server mencoba membuat record idempotensi dengan status processing.
  5. Jika berhasil, server menjalankan transaksi bisnis untuk membuat order.
  6. Setelah sukses, server menyimpan response ke record idempotensi sebagai completed.
  7. Server mengembalikan response ke client.

Alur retry identik

  1. Client tidak menerima respons karena timeout.
  2. Client mengirim ulang request yang sama dengan key yang sama.
  3. Server menemukan record idempotensi yang sudah completed dan fingerprint cocok.
  4. Server mengembalikan respons yang sama tanpa membuat order baru.

Alur key sama tetapi payload berbeda

  1. Client mengirim key yang sama untuk payload berbeda.
  2. Server menemukan record existing.
  3. Fingerprint tidak cocok.
  4. Server mengembalikan 409 Conflict.

Implementasi Laravel: Middleware + Service + Controller

Implementasi paling rapi biasanya memisahkan tiga tanggung jawab:

  • Middleware: memastikan header ada dan valid.
  • Service: mengelola lifecycle idempotency key.
  • Controller: fokus pada logika bisnis order.

Middleware untuk validasi header

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RequireIdempotencyKey
{
    public function handle(Request $request, Closure $next): Response
    {
        if ($request->isMethod('post')) {
            $key = $request->header('Idempotency-Key');

            if (!$key) {
                return response()->json([
                    'message' => 'Idempotency-Key header is required.'
                ], 400);
            }

            if (mb_strlen($key) > 255) {
                return response()->json([
                    'message' => 'Idempotency-Key is too long.'
                ], 400);
            }
        }

        return $next($request);
    }
}

Helper untuk fingerprint payload yang konsisten

Masalah umum pada fingerprint adalah JSON yang sama secara makna bisa punya urutan key berbeda. Solusinya, normalisasi payload sebelum di-hash.

namespace App\Support;

class CanonicalJson
{
    public static function encode(array $data): string
    {
        $normalized = self::sortRecursive($data);
        return json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    private static function sortRecursive($value)
    {
        if (!is_array($value)) {
            return $value;
        }

        $isAssoc = array_keys($value) !== range(0, count($value) - 1);

        if ($isAssoc) {
            ksort($value);
        }

        foreach ($value as $k => $v) {
            $v = self::sortRecursive($v);
        }

        return $value;
    }
}

Lalu fingerprint dibuat dari payload yang sudah lolos validasi:

$validated = $request->validated();
$fingerprint = hash('sha256', \App\Support\CanonicalJson::encode($validated));

Kenapa dari data tervalidasi? Karena field yang tidak relevan, default value, atau urutan key mentah bisa membuat hash berbeda padahal intent bisnisnya sama.

Contoh model sederhana

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class IdempotencyKey extends Model
{
    protected $fillable = [
        'user_id',
        'idempotency_key',
        'request_fingerprint',
        'status',
        'response_code',
        'response_body',
        'resource_type',
        'resource_id',
        'locked_until',
        'expires_at',
    ];

    protected $casts = [
        'response_body' => 'array',
        'locked_until' => 'datetime',
        'expires_at' => 'datetime',
    ];
}

Service untuk acquire dan replay

namespace App\Services;

use App\Models\IdempotencyKey;
use Illuminate\Support\Carbon;
use Illuminate\Database\QueryException;

class IdempotencyService
{
    public function begin(?int $userId, string $key, string $fingerprint, int $ttlMinutes = 1440): array
    {
        $existing = IdempotencyKey::where('user_id', $userId)
            ->where('idempotency_key', $key)
            ->first();

        if ($existing) {
            if ($existing->request_fingerprint !== $fingerprint) {
                return ['type' => 'conflict', 'record' => $existing];
            }

            if ($existing->status === 'completed') {
                return ['type' => 'replay', 'record' => $existing];
            }

            if ($existing->status === 'processing') {
                return ['type' => 'processing', 'record' => $existing];
            }
        }

        try {
            $record = IdempotencyKey::create([
                'user_id' => $userId,
                'idempotency_key' => $key,
                'request_fingerprint' => $fingerprint,
                'status' => 'processing',
                'locked_until' => Carbon::now()->addMinutes(5),
                'expires_at' => Carbon::now()->addMinutes($ttlMinutes),
            ]);

            return ['type' => 'started', 'record' => $record];
        } catch (QueryException $e) {
            $existing = IdempotencyKey::where('user_id', $userId)
                ->where('idempotency_key', $key)
                ->first();

            if ($existing && $existing->request_fingerprint !== $fingerprint) {
                return ['type' => 'conflict', 'record' => $existing];
            }

            if ($existing && $existing->status === 'completed') {
                return ['type' => 'replay', 'record' => $existing];
            }

            return ['type' => 'processing', 'record' => $existing];
        }
    }

    public function complete(IdempotencyKey $record, int $statusCode, array $responseBody, ?string $resourceType = null, ?int $resourceId = null): void
    {
        $record->update([
            'status' => 'completed',
            'response_code' => $statusCode,
            'response_body' => $responseBody,
            'resource_type' => $resourceType,
            'resource_id' => $resourceId,
            'locked_until' => null,
        ]);
    }

    public function fail(IdempotencyKey $record): void
    {
        $record->update([
            'status' => 'failed',
            'locked_until' => null,
        ]);
    }
}

Implementasi di atas sengaja sederhana. Pada sistem dengan concurrency tinggi, Anda biasanya menambahkan row locking atau penyimpanan lock di Redis untuk memperkecil race condition.

Contoh controller order

namespace App\Http\Controllers;

use App\Http\Requests\StoreOrderRequest;
use App\Models\Order;
use App\Services\IdempotencyService;
use App\Support\CanonicalJson;
use Illuminate\Support\Facades\DB;

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request, IdempotencyService $idempotency)
    {
        $userId = optional($request->user())->id;
        $key = $request->header('Idempotency-Key');
        $validated = $request->validated();
        $fingerprint = hash('sha256', CanonicalJson::encode($validated));

        $state = $idempotency->begin($userId, $key, $fingerprint);

        if ($state['type'] === 'conflict') {
            return response()->json([
                'message' => 'Idempotency-Key already used with different payload.'
            ], 409);
        }

        if ($state['type'] === 'replay') {
            return response()->json(
                $state['record']->response_body,
                $state['record']->response_code
            );
        }

        if ($state['type'] === 'processing') {
            return response()->json([
                'message' => 'Request with the same Idempotency-Key is still processing.'
            ], 409);
        }

        $record = $state['record'];

        try {
            $response = DB::transaction(function () use ($validated) {
                $order = Order::create([
                    'customer_id' => $validated['customer_id'],
                    'shipping_address' => $validated['shipping_address'],
                    'status' => 'created',
                ]);

                foreach ($validated['items'] as $item) {
                    $order->items()->create([
                        'sku' => $item['sku'],
                        'qty' => $item['qty'],
                        'price' => $item['price'],
                    ]);
                }

                return [
                    'order_id' => $order->id,
                    'status' => $order->status,
                ];
            });

            $idempotency->complete($record, 201, $response, 'order', $response['order_id']);

            return response()->json($response, 201);
        } catch (\Throwable $e) {
            $idempotency->fail($record);
            throw $e;
        }
    }
}

Validasi Payload Harus Konsisten

Jika ingin idempotensi bekerja benar, proses validasi harus deterministik. Beberapa aturan penting:

  • Gunakan payload yang sudah divalidasi dan dinormalisasi untuk fingerprint.
  • Hindari field yang nilainya berubah-ubah setiap request, seperti client_timestamp atau nonce acak, jika field itu tidak memengaruhi intent bisnis.
  • Jika ada default value, pastikan nilainya dibentuk konsisten sebelum fingerprint dibuat.
  • Jika urutan item tidak bermakna secara bisnis, pertimbangkan normalisasi urutan array. Jika urutan bermakna, jangan diubah.

Ini sering menjadi sumber bug: request yang sebenarnya sama dianggap berbeda karena serialisasi payload tidak konsisten.

Strategi TTL dan Pembersihan Data

Berapa lama TTL?

TTL tergantung kebutuhan bisnis dan pola retry client. Untuk order atau pembayaran, TTL biasanya disetel cukup lama agar retry dari client yang lambat masih aman. Yang penting, TTL harus lebih lama dari kemungkinan retry normal dari aplikasi client atau infrastruktur di depannya.

Pertimbangannya:

  • Semakin lama TTL, semakin aman terhadap retry terlambat.
  • Semakin lama TTL, semakin besar kebutuhan storage.
  • Jika terlalu pendek, client bisa retry setelah key kedaluwarsa dan operasi diproses ulang sebagai request baru.

Pembersihan record kedaluwarsa

Lakukan cleanup berkala, misalnya lewat scheduler Laravel:

use App\Models\IdempotencyKey;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    IdempotencyKey::where('expires_at', '<', now())->delete();
})->hourly();

Jika volume tinggi, pertimbangkan batch delete agar tidak membebani database.

Race Condition dan Cara Menanganinya

Masalah utama pada idempotency key bukan konsepnya, melainkan concurrency. Dua request dengan key yang sama bisa masuk hampir bersamaan.

Kenapa unique constraint penting

Unique index pada kombinasi user_id + idempotency_key adalah garis pertahanan pertama. Tanpa ini, dua request paralel bisa sama-sama membuat record baru dan keduanya memproses order.

Apakah unique constraint saja cukup?

Tidak selalu. Unique constraint mencegah dua record key yang sama, tetapi Anda tetap perlu mengelola state processing, completed, dan recovery saat proses gagal. Pada sistem sibuk, kombinasi yang umum adalah:

  • Database unique constraint untuk integritas.
  • Row-level locking atau Redis lock untuk proses yang sangat sensitif.
  • Transaksi database untuk write utama.

Kapan Redis lock berguna

Jika satu operasi melibatkan beberapa langkah berat atau layanan eksternal, lock sementara di Redis bisa membantu menandai bahwa key sedang diproses. Namun jangan menggantikan database constraint sepenuhnya dengan lock in-memory, karena lock bisa hilang saat restart atau expiry yang tidak tepat.

Crash Setelah Write ke Database: Kasus yang Sering Terlupakan

Skenario sulit yang sering terjadi:

  1. Server berhasil membuat order di database.
  2. Sebelum record idempotensi di-update menjadi completed, proses PHP crash atau koneksi ke client terputus.
  3. Client melakukan retry dengan key yang sama.

Jika implementasi Anda hanya melihat status idempotensi dan menemukan processing, sistem bisa bingung: apakah order sudah dibuat atau belum?

Cara mengurangi risiko

  • Simpan operasi bisnis utama dan pembuatan resource dalam transaksi database.
  • Jika memungkinkan, simpan keterkaitan antara resource dan idempotency key.
  • Saat recovery, jika status masih processing tetapi resource_id atau jejak bisnis sudah ditemukan, selesaikan record idempotensi menjadi completed.

Pendekatan yang lebih kuat adalah menyimpan referensi idempotency key pada tabel order atau tabel audit, sehingga proses recovery dapat mencari apakah order untuk key tersebut sebenarnya sudah tercipta.

Schema::table('orders', function ($table) {
    $table->string('idempotency_key', 255)->nullable()->index();
});

Lalu dalam transaksi pembuatan order, isi kolom tersebut. Dengan begitu, saat retry datang dan record idempotensi masih processing, Anda bisa mencari order berdasarkan key itu sebelum memutuskan membuat ulang.

Proses Parsial dan Integrasi ke Layanan Eksternal

Idempotensi di API Laravel tidak otomatis membuat seluruh sistem end-to-end menjadi idempoten, terutama jika ada panggilan ke payment gateway, ERP, atau service lain.

Contoh masalah

  • Order lokal sudah tersimpan, tetapi panggilan ke payment service timeout.
  • Client retry, server harus memutuskan apakah mengulangi seluruh proses atau melanjutkan dari state parsial.

Pendekatan yang lebih aman

  • Pisahkan pembuatan order dari side effect eksternal bila memungkinkan.
  • Gunakan status state machine, misalnya created, payment_pending, paid, failed.
  • Jika memanggil service eksternal, lihat apakah mereka juga mendukung idempotency key.
  • Untuk proses async, pertimbangkan pola outbox agar event/pesan tidak hilang setelah commit database.

Dengan kata lain, idempotency key di endpoint masuk mencegah order ganda pada sisi API Anda, tetapi tidak otomatis menyelesaikan idempotensi lintas sistem tanpa desain tambahan.

Contoh Respons API

Request pertama sukses

HTTP/1.1 201 Created
Content-Type: application/json

{
  "order_id": 9876,
  "status": "created"
}

Retry identik

HTTP/1.1 201 Created
Content-Type: application/json

{
  "order_id": 9876,
  "status": "created"
}

Key sama, payload berbeda

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "message": "Idempotency-Key already used with different payload."
}

Request masih diproses

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "message": "Request with the same Idempotency-Key is still processing."
}

Beberapa tim memilih 425 atau 202 untuk status tertentu, tetapi 409 biasanya paling mudah dipahami selama dokumentasinya jelas.

Checklist Testing

Jangan berhenti di unit test biasa. Fitur ini harus diuji pada kondisi retry dan concurrency.

Skenario yang wajib dites

  • Request pertama sukses, order tercipta satu kali.
  • Retry dengan key dan payload sama mengembalikan respons yang sama.
  • Retry dengan key sama dan payload berbeda menghasilkan 409.
  • Dua request paralel dengan key sama tidak menghasilkan dua order.
  • Timeout di client lalu retry tetap tidak menggandakan order.
  • Proses gagal sebelum commit tidak menyisakan order setengah jadi.
  • Crash setelah order tersimpan tetapi sebelum response dikembalikan bisa direcovery.
  • Record TTL kedaluwarsa dibersihkan sesuai kebijakan.
  • User A dan User B dengan key yang sama tidak saling bentrok.

Contoh ide integration test

  • Kirim request dua kali dengan header sama dalam test feature.
  • Simulasikan exception setelah write utama lalu pastikan retry tidak membuat duplikasi tak terdeteksi.
  • Jika memakai queue atau proses async, uji transisi state end-to-end.

Kesalahan Implementasi yang Umum

  • Hanya mengecek duplicate order tanpa menyimpan respons awal.
  • Tidak menyimpan fingerprint, sehingga key yang sama bisa dipakai untuk payload lain.
  • Tidak ada unique constraint pada storage idempotensi.
  • Meng-hash raw JSON mentah tanpa normalisasi, sehingga request identik dianggap berbeda.
  • TTL terlalu pendek, membuat retry sah berubah jadi request baru.
  • Menganggap status failed selalu aman untuk retry, padahal mungkin ada write parsial.
  • Tidak mengikat key ke user/tenant, sehingga konflik lintas pengguna bisa terjadi.
  • Menyimpan seluruh request/response sensitif tanpa filter, yang bisa menambah risiko kebocoran data.

Kapan Memilih Database Saja, Redis, atau Kombinasi Keduanya

Database saja

Cocok jika:

  • Traffic tidak ekstrem.
  • Operasi utama memang berbasis transaksi database.
  • Anda ingin integritas kuat dan implementasi lebih sederhana.

Redis + Database

Cocok jika:

  • Concurrency tinggi.
  • Perlu lock cepat untuk request paralel.
  • Masih ingin persistence dan recovery dari database.

Untuk banyak sistem Laravel, database sebagai source of truth ditambah optimasi lock seperlunya adalah pilihan paling aman.

Penutup

Laravel API Idempotency Key bukan sekadar header tambahan, tetapi kontrak retry yang eksplisit antara client dan server. Kunci implementasi yang benar ada pada kombinasi: header Idempotency-Key, fingerprint payload yang konsisten, penyimpanan status dan response, unique constraint, TTL yang masuk akal, serta strategi recovery untuk crash dan proses parsial.

Jika endpoint Anda membuat order atau transaksi, jangan hanya mengandalkan duplicate check biasa. Terapkan idempotency key dengan desain yang jelas agar retry karena timeout tidak berubah menjadi order ganda yang sulit dibersihkan setelah masuk ke produksi.