Kontrak API stabil adalah batas tegas antara sistem Anda dan integrator. Jika boundary ini dirancang dengan benar, tim bebas mengubah database, service internal, queue, atau alur orkestrasi tanpa memaksa klien ikut berubah. Kuncinya bukan membuat API yang tidak pernah berubah, tetapi membuat perubahan yang terprediksi, kompatibel, dan aman untuk dioperasikan.

Dalam integrasi backend nyata, masalah biasanya bukan pada endpoint yang gagal total, melainkan pada perubahan kecil yang tampak sepele: field dihapus, tipe data berubah, retry menggandakan transaksi, webhook datang tidak berurutan, atau format error berbeda antar endpoint. Artikel ini membahas praktik praktis untuk menjaga kontrak API tetap stabil saat implementasi internal sering berubah.

Mengapa kontrak API harus lebih stabil daripada implementasi internal

Implementasi internal boleh berevolusi: tabel bisa dipecah, service bisa dipindah, proses sinkron bisa menjadi asynchronous, dan validasi bisa dipindah ke layer lain. Namun klien eksternal tidak memiliki konteks perubahan itu. Mereka hanya melihat request, response, status code, semantik retry, dan perilaku timeout.

Prinsip dasarnya:

  • Model API bukan cerminan langsung database. Jika payload mengikuti struktur tabel 1:1, setiap refactor internal berisiko menjadi breaking change.
  • Semantik harus eksplisit. Misalnya status pending berarti apa, kapan transaksi dianggap final, dan kapan klien boleh retry.
  • Tambahkan, jangan ganti, jika masih bisa. Perubahan additive jauh lebih aman daripada menghapus atau mengubah arti field lama.

Aturan praktis: anggap kontrak API sebagai produk publik, walaupun pemakainya baru satu tim lain di dalam perusahaan.

Prinsip desain kontrak API stabil

1. Pisahkan model publik dari model internal

Jangan expose nama kolom, enum internal, atau struktur nested yang hanya masuk akal untuk implementasi saat ini. Buat representasi yang stabil dan berorientasi pada use case integrator.

Contoh buruk:

  • Field status_code berisi angka internal yang bisa berubah.
  • Field worker_shard atau db_partition ikut keluar ke response.
  • Nama field mengikuti nama tabel, bukan domain bisnis.

Contoh lebih baik:

{
  "payment_id": "pay_01HZX...",
  "status": "processing",
  "amount": {
    "value": "150000.00",
    "currency": "IDR"
  },
  "created_at": "2025-01-10T08:15:30Z"
}

Payload di atas tidak peduli apakah di belakang layar pembayaran diproses oleh satu service, tiga queue, atau tabel yang berbeda.

2. Definisikan perubahan additive vs breaking change

Tim sering salah menganggap semua perubahan kecil aman. Padahal tidak semua perubahan kompatibel.

Umumnya additive change:

  • Menambah field baru yang opsional di response.
  • Menambah endpoint baru.
  • Menambah nilai enum baru jika kontrak sejak awal mengizinkan klien menangani nilai tak dikenal dengan aman.
  • Menambah header response non-wajib.

Umumnya breaking change:

  • Menghapus field existing.
  • Mengubah tipe field, misalnya string menjadi object.
  • Mengubah arti field tanpa mengganti nama.
  • Mengubah status code untuk skenario yang sama.
  • Mewajibkan field request yang sebelumnya opsional.
  • Mengubah urutan atau semantik signature webhook.

Contoh yang sering dianggap aman padahal breaking:

// Sebelum
{
  "status": "paid"
}

// Sesudah (breaking)
{
  "status": {
    "code": "paid"
  }
}

Walaupun informasinya sama, tipe data berubah dan parser klien bisa gagal.

3. Toleran terhadap pembaca, disiplin pada penulis

Prinsip lama yang masih berguna: be conservative in what you send, be liberal in what you accept, tetapi terapkan dengan hati-hati. Server sebaiknya:

  • Mengirim payload yang konsisten, lengkap, dan terstandar.
  • Menerima field tambahan yang tidak dikenal jika itu tidak membahayakan validasi.
  • Tidak bergantung pada urutan field JSON.

Namun jangan sampai toleransi ini membuat validasi kabur. Untuk request yang memengaruhi transaksi, field wajib tetap harus divalidasi ketat.

Versioning seperlunya, bukan refleks pertama

Versioning penting, tetapi bukan alasan untuk sering membuat /v2, /v3, dan seterusnya. Jika setiap perubahan kecil berujung versi baru, biaya support akan cepat naik.

Kapan cukup tanpa versi baru

Jika perubahan bersifat additive dan klien lama tetap berjalan tanpa modifikasi, biasanya tidak perlu versi baru. Contoh:

  • Menambah field metadata opsional di response.
  • Menambah endpoint pencarian baru tanpa mengubah endpoint lama.
  • Menambah event webhook baru dengan tipe event berbeda.

Kapan versi baru masuk akal

Gunakan versi baru jika:

  • Anda harus menghapus atau mengganti semantik field penting.
  • Representasi resource berubah besar dan tidak kompatibel.
  • Model autentikasi atau skema penandatanganan berubah.
  • Perilaku retry, timeout, atau status code berubah secara mendasar.

Pendekatan praktis

Untuk tim kecil, pendekatan paling sederhana biasanya:

  • Satu versi mayor di path atau header.
  • Perubahan additive tetap di versi yang sama.
  • Breaking change dikumpulkan dan dirilis terencana.
  • Versi lama diberi masa transisi dan tanggal penghentian yang jelas.

Yang penting bukan media versioning-nya, tetapi disiplin kompatibilitasnya.

Idempotency key untuk POST yang bisa di-retry

POST sering dipakai untuk operasi yang membuat efek samping: membuat pembayaran, order, invoice, atau ticket. Masalahnya, di jaringan nyata klien bisa timeout padahal server sebenarnya berhasil. Jika klien mengirim ulang tanpa mekanisme idempotensi, hasilnya bisa duplikat.

Kapan perlu idempotency key

Gunakan idempotency key untuk operasi POST yang:

  • Membuat resource baru.
  • Memicu transaksi finansial atau efek samping non-reversibel.
  • Mungkin di-retry otomatis oleh klien, proxy, worker, atau job scheduler.

Contoh request

POST /payments
Idempotency-Key: 8f0b2c3e-67fa-4d9d-8d4a-9ef1d9f08abc
Content-Type: application/json

{
  "merchant_reference": "INV-2025-00042",
  "amount": {
    "value": "150000.00",
    "currency": "IDR"
  },
  "customer": {
    "id": "cust_12345"
  }
}

Perilaku yang diharapkan

  • Jika request pertama sukses, retry dengan Idempotency-Key yang sama harus mengembalikan hasil yang sama, bukan membuat pembayaran baru.
  • Jika key sama dipakai dengan payload berbeda, server sebaiknya menolak karena indikasi bug atau penyalahgunaan.
  • Server perlu menyimpan jejak key, fingerprint request, status akhir, dan response yang relevan dalam jangka waktu tertentu.

Contoh response sukses

{
  "payment_id": "pay_01HZX9Y7M4Y2",
  "status": "processing",
  "merchant_reference": "INV-2025-00042",
  "amount": {
    "value": "150000.00",
    "currency": "IDR"
  }
}

Trade-off implementasi

  • Butuh storage tambahan. Anda harus menyimpan key dan hasil operasi.
  • Perlu definisi masa berlaku. Terlalu pendek berisiko duplikasi, terlalu panjang menambah beban penyimpanan.
  • Perlu canonicalization payload. Jika fingerprint request dibuat asal-asalan, perbedaan urutan field JSON bisa keliru dianggap payload berbeda.

Kesalahan umum adalah mengandalkan merchant_reference sebagai satu-satunya deduplikasi. Itu bisa membantu, tetapi tidak selalu cukup karena satu merchant reference belum tentu identik dengan satu operasi teknis.

Error schema konsisten agar klien mudah menangani kegagalan

Salah satu sumber integrasi rapuh adalah format error yang berubah-ubah antar endpoint. Ada endpoint yang mengembalikan string, ada yang object, ada yang menaruh kode error di field berbeda. Akibatnya klien menulis banyak percabangan yang sulit dipelihara.

Struktur error yang disarankan

{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "Field amount.value harus berupa angka desimal positif.",
    "details": [
      {
        "field": "amount.value",
        "reason": "invalid_format"
      }
    ],
    "request_id": "req_7d9c1c2a"
  }
}

Komponen penting:

  • code: stabil dan dapat dipakai logika klien.
  • message: dapat dibaca manusia, boleh berubah redaksinya.
  • details: rincian validasi atau error domain.
  • request_id: membantu debugging dengan log server.

Status code yang konsisten

Gunakan status code HTTP sesuai kelas masalahnya, lalu kombinasikan dengan error.code yang stabil. Contoh pola umum:

  • 400 untuk request tidak valid.
  • 401 untuk autentikasi gagal.
  • 403 untuk akses ditolak.
  • 404 untuk resource tidak ditemukan.
  • 409 untuk konflik, termasuk reuse idempotency key dengan payload berbeda.
  • 429 untuk rate limit.
  • 500 atau 502/503/504 untuk gangguan server atau dependency.

Jangan memaksa semua error menjadi 200 OK dengan field success: false. Itu anti-pattern karena mematahkan perilaku standar client, monitoring, retry, dan observability.

Membuat endpoint aman untuk retry

Retry adalah kenyataan operasional, bukan pengecualian. Timeout, putus koneksi, restart worker, dan failover akan terjadi. Karena itu, endpoint perlu dirancang agar retry tidak menghasilkan efek samping yang salah.

Bedakan operasi baca, tulis, dan transisi state

  • GET seharusnya aman untuk diulang dan tidak mengubah state.
  • PUT cocok untuk update penuh yang idempotent jika semantiknya jelas.
  • PATCH perlu hati-hati; tidak semua patch idempotent.
  • POST butuh idempotency key jika ada risiko duplikasi efek samping.

Gunakan state machine yang jelas

Jika resource memiliki lifecycle, definisikan transisinya secara eksplisit. Misalnya pembayaran hanya boleh bergerak dari processing ke succeeded atau failed, dan tidak boleh kembali ke created. Ini mengurangi perilaku ambigu saat retry atau event datang terlambat.

Hindari operasi sinkron yang terlalu panjang

Jika proses backend melibatkan banyak dependency atau durasi tidak pasti, pertimbangkan pola:

  1. POST membuat pekerjaan atau resource dengan status awal processing.
  2. Server segera merespons dengan identifier dan status saat ini.
  3. Klien memeriksa status melalui endpoint GET atau menunggu webhook.

Pola ini lebih stabil dibanding menahan koneksi terlalu lama sambil berharap semua dependency selesai tepat waktu.

Webhook: minimal sekali kirim, bukan tepat sekali

Banyak integrasi gagal bukan karena API request utama, tetapi karena asumsi yang salah pada webhook. Dalam praktiknya, webhook biasanya memiliki semantik at least once delivery: event bisa terkirim lebih dari sekali, bisa tertunda, dan tidak selalu datang dalam urutan yang diharapkan.

Kontrak webhook yang sehat

{
  "id": "evt_01J1ABCXYZ",
  "type": "payment.succeeded",
  "created_at": "2025-01-10T08:16:02Z",
  "data": {
    "payment_id": "pay_01HZX9Y7M4Y2",
    "merchant_reference": "INV-2025-00042",
    "status": "succeeded"
  }
}

Praktik yang disarankan:

  • Setiap event punya event id unik. Konsumen bisa mendeduplikasi.
  • Tanda tangani payload atau request. Konsumen harus bisa memverifikasi sumbernya.
  • Jangan asumsi urutan mutlak. Konsumen sebaiknya melihat state resource terbaru, bukan hanya urutan event.
  • Respons webhook cepat. Endpoint penerima sebaiknya validasi ringan, simpan event, lalu proses asynchronous.

Contoh respons penerima webhook

HTTP/1.1 200 OK
Content-Type: application/json

{
  "received": true
}

Jangan lakukan proses berat langsung di handler webhook jika bisa dihindari. Jika timeout terjadi, pengirim kemungkinan akan retry dan Anda berisiko memproses event yang sama berkali-kali tanpa deduplikasi yang baik.

Kesalahan umum pada webhook

  • Menganggap satu event hanya akan datang sekali.
  • Mengandalkan urutan event sebagai sumber kebenaran tunggal.
  • Mengirim payload yang terlalu minim sehingga penerima harus menebak konteks.
  • Tidak menyertakan event id dan timestamp.

Timeout, retry policy, dan observability

Kontrak API yang stabil tidak hanya soal bentuk JSON, tetapi juga perilaku saat jaringan dan dependency bermasalah.

Tetapkan ekspektasi timeout

Dokumentasikan secara praktis:

  • Endpoint mana yang biasanya cepat dan sinkron.
  • Endpoint mana yang bisa mengembalikan status processing.
  • Kapan klien disarankan retry dan kapan tidak.

Anda tidak harus menjanjikan angka latensi yang kaku jika belum siap mengelolanya, tetapi semantik operasionalnya harus jelas.

Bedakan retry yang aman dan tidak aman

  • Aman di-retry: GET, webhook delivery dengan event id, POST ber-idempotency key.
  • Tidak otomatis aman: POST tanpa idempotency, PATCH inkremental, operasi yang memicu side effect eksternal tanpa deduplikasi.

Sertakan request ID

Tambahkan request ID pada response dan log agar tim integrasi bisa melaporkan masalah dengan referensi yang bisa ditelusuri. Ini sering lebih berguna daripada error message panjang.

Anti-pattern yang sering membuat kontrak API rapuh

  • Expose struktur database langsung ke API.
  • Mengubah tipe field tanpa versi baru.
  • Semua error menjadi 200 OK.
  • POST membuat transaksi tanpa idempotency key.
  • Webhook tanpa deduplication identifier.
  • Nilai enum diasumsikan tertutup selamanya. Padahal cepat atau lambat nilai baru akan muncul.
  • Field nullable tanpa semantik jelas. null, kosong, dan tidak ada field bisa berarti hal yang berbeda.
  • Dokumentasi tertinggal dari implementasi. Ini pada akhirnya sama buruknya dengan kontrak yang tidak stabil.

Compatibility checklist sebelum rilis

Sebelum merilis perubahan API, lakukan pengecekan sederhana berikut:

  1. Apakah ada field yang dihapus, diganti nama, atau berubah tipe?
  2. Apakah ada perubahan arti field lama tanpa versi baru?
  3. Apakah status code untuk skenario existing berubah?
  4. Apakah format error tetap konsisten dengan endpoint lain?
  5. Apakah endpoint POST yang bisa di-retry sudah punya idempotency key?
  6. Apakah retry dengan key yang sama dan payload yang sama mengembalikan hasil konsisten?
  7. Apakah key yang sama dengan payload berbeda ditolak jelas?
  8. Apakah webhook punya event id, timestamp, dan mekanisme signature?
  9. Apakah konsumen webhook bisa menerima event duplikat atau out-of-order?
  10. Apakah perubahan enum baru aman untuk klien lama?
  11. Apakah timeout dan perilaku asynchronous sudah didokumentasikan?
  12. Apakah ada contract test atau replay test terhadap klien penting?

Checklist review kontrak API untuk tim kecil

Tim kecil sering tidak punya platform team khusus atau governance berat. Karena itu, checklist review harus singkat tetapi efektif.

Checklist desain

  • Apakah nama field berorientasi domain, bukan implementasi internal?
  • Apakah response cukup stabil jika storage atau service internal diganti?
  • Apakah field baru benar-benar perlu, atau hanya bocoran detail internal?

Checklist operasional

  • Apakah endpoint ini aman terhadap retry?
  • Apakah ada request ID di log dan response?
  • Apakah timeout realistis dan tidak memaksa proses panjang tetap sinkron?

Checklist kompatibilitas

  • Apakah klien lama tetap berfungsi tanpa perubahan?
  • Apakah parser klien yang ketat akan pecah karena tipe atau struktur berubah?
  • Apakah dokumentasi contoh payload sudah diperbarui?

Checklist webhook

  • Apakah event bisa dideduplikasi?
  • Apakah penerima tidak harus bergantung pada urutan event?
  • Apakah event membawa informasi minimum yang cukup untuk diproses atau diverifikasi?

Contoh kontrak yang lebih tahan perubahan

Berikut contoh sederhana untuk pembuatan pembayaran yang aman terhadap retry dan lebih mudah dipertahankan dalam jangka panjang.

POST /payments
Idempotency-Key: 8f0b2c3e-67fa-4d9d-8d4a-9ef1d9f08abc
Content-Type: application/json

{
  "merchant_reference": "INV-2025-00042",
  "amount": {
    "value": "150000.00",
    "currency": "IDR"
  },
  "callback_url": "https://merchant.example.com/payment-callback"
}
HTTP/1.1 202 Accepted
Content-Type: application/json
X-Request-Id: req_7d9c1c2a

{
  "payment_id": "pay_01HZX9Y7M4Y2",
  "status": "processing",
  "merchant_reference": "INV-2025-00042",
  "amount": {
    "value": "150000.00",
    "currency": "IDR"
  },
  "links": {
    "self": "/payments/pay_01HZX9Y7M4Y2"
  }
}

Kenapa pola ini bekerja:

  • 202 Accepted memberi sinyal bahwa proses bisa berlanjut asynchronous.
  • payment_id menjadi identitas publik yang stabil.
  • status eksplisit, sehingga klien tahu perlu polling atau menunggu webhook.
  • Idempotency-Key mencegah duplikasi saat timeout atau retry.
  • links.self memberi jalur yang jelas untuk pengecekan status tanpa mengikat klien ke detail internal.

Penutup

Merancang kontrak API stabil untuk integrasi yang sering berubah berarti menerima bahwa perubahan internal pasti terjadi, lalu membangun boundary yang menyerap perubahan itu. Praktik yang paling berdampak biasanya bukan yang paling rumit: bedakan additive dan breaking change, gunakan versioning seperlunya, terapkan idempotency key pada POST yang berisiko, samakan schema error, buat endpoint aman untuk retry, dan desain webhook dengan asumsi delivery minimal sekali.

Jika tim Anda kecil, mulai dari checklist yang konsisten sebelum rilis. Kontrak API yang stabil tidak muncul dari dokumentasi saja, tetapi dari kebiasaan engineering yang menjaga kompatibilitas setiap kali kode berubah.