Ada produk kompleks yang bisa lahir dari satu orang dengan disiplin desain yang sangat tinggi. Pelajarannya relevan untuk backend: saat resource terbatas, Anda tidak punya kemewahan untuk terus-menerus memecah integrasi karena kontrak API berubah, retry menggandakan transaksi, atau webhook tidak bisa dipercaya.

Desain API untuk produk solo bukan berarti menyederhanakan masalah secara naif. Justru tujuannya adalah membuat API yang tahan perubahan, mudah dioperasikan, dan aman saat menghadapi kegagalan jaringan, request dobel, timeout, serta event yang datang tidak berurutan. Kuncinya ada pada kontrak yang stabil, retry yang aman, dan idempotensi yang dirancang sejak awal.

Mengapa kontrak API harus stabil sejak awal

Untuk produk yang dikerjakan sendirian atau oleh tim kecil, biaya perubahan bukan hanya soal menulis ulang handler. Dampaknya menjalar ke dokumentasi, SDK internal, webhook consumer, dashboard admin, log observability, hingga prosedur support. API yang stabil mengurangi pekerjaan berulang dan memudahkan Anda menambah fitur tanpa merusak client lama.

Kontrak API yang stabil biasanya punya karakteristik berikut:

  • Shape respons konsisten, termasuk saat error.
  • Field tidak berubah makna setelah dipublikasikan.
  • Perubahan breaking jarang dilakukan dan jelas jalur migrasinya.
  • Operasi tulis aman terhadap retry dan request duplikat.
  • Perilaku async dijelaskan eksplisit: status, webhook, deduplikasi, dan urutan event.

Stabil bukan berarti kaku. Anda tetap bisa berevolusi dengan menambah field opsional, menambah endpoint baru, atau memperkenalkan workflow async tanpa merusak client lama.

Prinsip kontrak API yang tahan perubahan

1. Tambah, jangan ubah, bila masih bisa

Perubahan paling aman adalah perubahan yang bersifat aditif: menambah field baru, header baru, atau endpoint baru. Sebaliknya, mengubah tipe data, mengganti makna field, atau memindahkan field yang sudah dipakai client adalah sumber masalah integrasi.

Contoh aman:

  • Menambah field processing_details yang opsional.
  • Menambah status baru yang sudah terdokumentasi sebagai kemungkinan masa depan.
  • Menambah endpoint /v1/payments/{id}/cancel tanpa mengubah respons POST /v1/payments.

Contoh berisiko:

  • Mengubah amount dari integer sen ke string desimal.
  • Mengganti status=processing menjadi status=pending tanpa transisi.
  • Menghapus field lama karena dianggap “sudah tidak dipakai”.

2. Gunakan identifier yang stabil

ID publik sebaiknya tidak bergantung pada detail internal yang bisa berubah, seperti auto-increment yang terekspos bersama shard strategy. Yang penting bukan formatnya mewah, tetapi stabil, unik, dan aman dipakai lintas sistem.

Jika Anda perlu ID internal dan eksternal, pisahkan. Misalnya, database memakai primary key internal, sedangkan API mengekspos payment_id publik yang tidak berubah.

3. Bedakan create, update, dan execute

Banyak API menjadi sulit dibuat idempotent karena satu endpoint memuat terlalu banyak makna. Bedakan operasi berikut:

  • Create: membuat resource baru.
  • Update: mengubah atribut resource yang sudah ada.
  • Execute/action: menjalankan aksi bisnis, seperti capture, refund, resend, atau cancel.

Pemisahan ini memudahkan desain retry, kontrol status, dan audit log.

Versioning seperlunya, bukan reflex

Versioning sering dipakai terlalu cepat untuk menutupi kontrak yang belum disiplin. Untuk produk kecil, menambah versi besar terus-menerus justru menambah beban maintenance. Gunakan versioning bila memang ada perubahan breaking yang tidak bisa dihindari.

Kapan tidak perlu versi baru

  • Menambah field baru yang opsional.
  • Menambah endpoint baru.
  • Menambah nilai enum yang sudah dinyatakan bisa bertambah.
  • Memperbaiki bug tanpa mengubah kontrak eksternal.

Kapan versi baru layak dipertimbangkan

  • Mengubah struktur respons secara breaking.
  • Mengubah semantik field yang sudah dipakai client.
  • Menghapus field atau endpoint lama.
  • Mengubah model autentikasi atau alur request secara fundamental.

Pendekatan praktis

Untuk banyak produk solo, versi di path seperti /v1 cukup jelas dan mudah dioperasikan. Yang lebih penting dari mekanisme versi adalah kebijakan evolusinya:

  • Jangan bikin versi baru untuk perubahan kecil.
  • Tentukan masa dukungan versi lama.
  • Komunikasikan deprecation dengan header, changelog, dan tenggat yang jelas.
  • Pastikan log dan metrics bisa dibedakan per versi.

Aturan sederhana: jika perubahan masih bisa dibuat kompatibel ke belakang, pilih perubahan aditif. Simpan version bump untuk kasus yang benar-benar breaking.

Skema error konsisten: dasar observability dan retry

Client butuh lebih dari sekadar status code. Mereka perlu tahu apa yang gagal, apakah aman di-retry, dan apa langkah berikutnya. Karena itu, bentuk error response harus konsisten di seluruh endpoint.

Struktur error yang disarankan

HTTP/1.1 409 Conflict
Content-Type: application/json
Request-Id: req_9f31c2

{
  "error": {
    "code": "idempotency_conflict",
    "message": "Permintaan dengan Idempotency-Key yang sama memiliki payload berbeda.",
    "retryable": false,
    "details": {
      "field": "Idempotency-Key"
    }
  }
}

Field minimal yang berguna:

  • code: kode mesin yang stabil untuk branching logic client.
  • message: penjelasan manusia yang ringkas.
  • retryable: petunjuk apakah aman dicoba ulang.
  • details: konteks tambahan seperti field invalid, status resource, atau batas rate.

Pemetaan status code yang realistis

  • 400 Bad Request: payload salah format atau field wajib hilang.
  • 401 Unauthorized: token tidak ada atau tidak valid.
  • 403 Forbidden: autentikasi valid tetapi tidak punya izin.
  • 404 Not Found: resource tidak ditemukan.
  • 409 Conflict: konflik status, duplikasi, atau idempotency conflict.
  • 422 Unprocessable Entity: format valid tetapi gagal aturan bisnis/validasi domain.
  • 429 Too Many Requests: rate limit tercapai.
  • 500/502/503/504: kegagalan server atau upstream; umumnya kandidat retry dengan backoff.

Yang sering salah adalah memakai 400 untuk semua hal, sehingga client tidak tahu kapan harus memperbaiki input dan kapan boleh retry.

Idempotensi untuk POST: wajib untuk operasi kritis

Masalah klasik API write adalah client tidak tahu apakah request sebelumnya berhasil saat koneksi putus atau timeout terjadi. Jika client mengirim ulang POST /payments tanpa mekanisme idempotensi, hasilnya bisa dobel: dua pembayaran, dua order, atau dua email penting.

Solusinya adalah idempotency key: client mengirim key unik untuk satu niat operasi bisnis. Server menyimpan hasil dari request pertama, lalu mengembalikan hasil yang sama untuk retry berikutnya dengan key yang sama.

Contoh request create dengan idempotency key

POST /v1/payments HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token>
Idempotency-Key: 0f7b18d7-8f9b-4d07-a8db-0b8b5eb9b4d4

{
  "order_id": "ord_12345",
  "amount": 150000,
  "currency": "IDR",
  "customer_id": "cus_789"
}

Respons pertama:

HTTP/1.1 201 Created
Content-Type: application/json
Request-Id: req_a1

{
  "id": "pay_001",
  "order_id": "ord_12345",
  "amount": 150000,
  "currency": "IDR",
  "status": "processing",
  "created_at": "2026-07-10T09:00:00Z"
}

Jika client retry dengan key dan payload yang sama, server sebaiknya mengembalikan hasil yang ekuivalen, misalnya:

HTTP/1.1 200 OK
Content-Type: application/json
Request-Id: req_a2

{
  "id": "pay_001",
  "order_id": "ord_12345",
  "amount": 150000,
  "currency": "IDR",
  "status": "processing",
  "created_at": "2026-07-10T09:00:00Z"
}

Aturan implementasi idempotensi yang penting

  1. Key harus unik per niat operasi, bukan per user selamanya. Satu klik “bayar” = satu key.
  2. Payload harus dikaitkan dengan key. Jika key sama dipakai untuk payload berbeda, balas 409 Conflict.
  3. Simpan status proses dan hasil akhir, bukan hanya “pernah dipakai”.
  4. Buat penyimpanan atomik agar dua request paralel dengan key sama tidak lolos bersamaan.
  5. Tentukan TTL retensi key sesuai kebutuhan bisnis dan pola retry.

Sketsa model data

idempotency_records
- key
- request_hash
- resource_type
- resource_id
- status            // in_progress, completed, failed
- response_status
- response_body
- created_at
- expires_at

request_hash berguna untuk mendeteksi key sama dengan payload berbeda. response_body membantu mengembalikan hasil yang konsisten pada retry berikutnya.

Race condition yang sering terjadi

Bug umum adalah alur seperti ini:

  1. Cek apakah key sudah ada.
  2. Kalau belum, proses transaksi.
  3. Setelah selesai, simpan record idempotensi.

Di bawah concurrency, dua request bisa sama-sama lolos langkah pertama sebelum salah satunya sempat menyimpan hasil. Solusinya adalah membuat insert/lock idempotency record terjadi sebelum efek samping bisnis dijalankan, lalu proses di dalam transaksi atau mekanisme locking yang setara.

Strategi retry yang aman

Retry tidak boleh dianggap fitur client semata. Server harus mendesain endpoint sehingga retry dapat diprediksi, dan dokumentasi harus menjelaskan kapan retry aman dilakukan.

Kapan retry aman

  • GET, HEAD, dan request baca lain umumnya aman, selama server memang tidak memberi efek samping tersembunyi.
  • POST aman di-retry hanya jika didukung idempotency key atau server punya deduplikasi bisnis yang kuat.
  • PUT dan DELETE secara konsep bisa idempotent, tetapi implementasi tetap harus hati-hati bila ada side effect lanjutan seperti event, email, atau job queue.

Prinsip retry di client

  • Gunakan timeout; jangan menunggu tanpa batas.
  • Pakai exponential backoff dengan jitter untuk menghindari thundering herd.
  • Retry untuk error yang masuk akal: timeout, connection reset, 502, 503, 504, dan kadang 429.
  • Jangan retry membabi buta untuk 4xx yang berarti input salah.
  • Untuk operasi create kritis, selalu kirim Idempotency-Key.

Contoh pedoman dokumentasi retry

Operasi: POST /v1/payments
Retry aman: Ya, jika menggunakan header Idempotency-Key yang sama dan payload identik.
Retry disarankan untuk: timeout jaringan, 502, 503, 504.
Retry tidak disarankan untuk: 400, 401, 403, 422.
Backoff: exponential backoff + jitter.

Timeout, status asinkron, dan kapan jangan memaksa sinkron

Banyak API menjadi rapuh karena mencoba menyelesaikan semua pekerjaan secara sinkron di dalam satu request HTTP: validasi, tulis database, panggil payment gateway, generate invoice, kirim email, update analitik. Jika salah satu lambat, seluruh request menggantung dan client mulai retry.

Kapan pilih respons sinkron

  • Operasi ringan dan cepat.
  • Hasil final bisa diketahui segera.
  • Client memang butuh jawaban final saat itu juga.

Kapan pilih pola async

  • Operasi bergantung pada sistem eksternal yang latensinya tidak pasti.
  • Waktu proses bisa lebih lama dari timeout wajar HTTP.
  • Anda ingin memisahkan penerimaan request dari eksekusi kerja berat.

Pola yang umum:

  1. Server menerima request dan memvalidasi hal dasar.
  2. Server membuat resource dengan status awal seperti pending atau processing.
  3. Server mengembalikan 202 Accepted atau 201 Created.
  4. Client memantau via polling endpoint status, atau menerima webhook saat selesai.

Contoh respons async

HTTP/1.1 202 Accepted
Content-Type: application/json
Request-Id: req_b1

{
  "id": "job_123",
  "status": "pending",
  "resource": {
    "type": "payment",
    "id": "pay_001"
  },
  "status_url": "/v1/payments/pay_001"
}

Untuk produk solo, desain async sering lebih aman daripada memaksakan sinkron. Anda mengurangi timeout panjang, menghindari retry ganda, dan bisa menambahkan queue atau worker tanpa mengubah kontrak utama.

Deduplication di luar Idempotency-Key

Idempotency key kuat untuk permintaan yang datang dari client yang patuh. Tetapi dalam praktik, Anda tetap perlu lapisan deduplikasi tambahan, terutama bila integrasi datang dari banyak sumber atau webhook pihak ketiga tidak selalu mengirim key yang sama.

Strategi deduplikasi yang berguna

  • Unique business key: misalnya satu external_order_id hanya boleh menghasilkan satu payment aktif.
  • Database unique constraint: garis pertahanan terakhir terhadap race condition.
  • Inbox table untuk event/webhook masuk: simpan event_id dan abaikan event duplikat.
  • Outbox pattern untuk event keluar: pastikan publish event sejalan dengan commit database.

Kesalahan umum adalah mengandalkan cache sementara untuk deduplikasi transaksi bisnis. Cache boleh membantu performa, tetapi penentu kebenaran akhir sebaiknya tetap ada di storage persisten dengan constraint yang jelas.

Webhook delivery yang dapat diandalkan

Webhook adalah pilihan baik saat hasil akhir tidak tersedia segera atau client tidak efisien melakukan polling terus-menerus. Namun webhook harus didesain dengan asumsi bahwa jaringan tidak andal: delivery bisa terlambat, dobel, atau datang tidak berurutan.

Aturan dasar webhook yang sehat

  • Anggap delivery minimal sekali (at least once), bukan tepat sekali.
  • Setiap event punya ID unik untuk deduplikasi di sisi penerima.
  • Tandatangani payload agar penerima bisa memverifikasi keaslian.
  • Retry dengan backoff saat endpoint penerima gagal.
  • Jangan jadikan urutan event sebagai jaminan tunggal.
  • Sertakan referensi resource sehingga penerima bisa melakukan fetch ulang state terkini bila ragu.

Contoh payload webhook

POST /webhooks/payment HTTP/1.1
Content-Type: application/json
X-Webhook-Id: evt_1001
X-Webhook-Signature: t=1720602000,v1=<signature>

{
  "id": "evt_1001",
  "type": "payment.completed",
  "created_at": "2026-07-10T09:05:00Z",
  "data": {
    "payment_id": "pay_001",
    "order_id": "ord_12345",
    "status": "completed",
    "amount": 150000,
    "currency": "IDR"
  }
}

Pedoman untuk penerima webhook

  1. Verifikasi signature dan timestamp.
  2. Cek apakah event_id sudah pernah diproses.
  3. Simpan event ke inbox table.
  4. Proses secara idempotent.
  5. Balas 2xx hanya jika event sudah diterima dengan aman.

Jika pemrosesan bisnis berat, respons 2xx sebaiknya dikirim setelah event berhasil dipersist, lalu pekerjaan lanjutan diteruskan ke queue internal.

Kapan memilih sinkron, polling, atau webhook

Pilih sinkron jika

  • Operasi cepat dan hasil final tersedia segera.
  • Kegagalan perlu diketahui user saat itu juga.
  • Tidak ada langkah eksternal yang rentan timeout panjang.

Pilih polling jika

  • Client sederhana dan mudah memanggil endpoint status.
  • Volume event tidak terlalu besar.
  • Anda ingin menghindari kompleksitas keamanan webhook di tahap awal.

Pilih webhook jika

  • Proses selesai secara asynchronous.
  • Client butuh notifikasi segera tanpa polling terus-menerus.
  • Integrator cukup matang untuk mengelola endpoint penerima, verifikasi signature, dan deduplikasi event.

Dalam banyak produk kecil, kombinasi terbaik adalah: create sinkron + status endpoint + webhook opsional. Pendekatan ini fleksibel untuk client sederhana maupun integrasi yang lebih matang.

Contoh desain endpoint yang lebih tahan gangguan

POST   /v1/payments              # create payment, dukung Idempotency-Key
GET    /v1/payments/{id}         # ambil status terkini
POST   /v1/payments/{id}/cancel  # action eksplisit, juga bisa idempotent
GET    /v1/events/{id}           # opsional untuk audit/event lookup
POST   /v1/webhook-endpoints     # registrasi endpoint webhook jika perlu

Respons create tidak harus memaksa status final. Yang penting client mendapat handle resource yang stabil dan tahu cara memantau progresnya.

Anti-pattern umum

  • Menganggap timeout berarti gagal total. Bisa jadi server berhasil memproses, tetapi responsnya tidak sampai ke client.
  • POST tanpa idempotensi untuk transaksi kritis. Ini resep klasik transaksi dobel.
  • Error response tidak konsisten. Client jadi sulit membuat retry policy yang benar.
  • Memakai 200 untuk semua keadaan. Observability dan handling client menjadi kabur.
  • Webhook dianggap exactly once. Pada kenyataannya, duplikasi harus diasumsikan.
  • Tidak menyimpan request ID dan correlation ID. Debug insiden jadi jauh lebih sulit.
  • Versioning terlalu dini untuk perubahan non-breaking, sehingga maintenance meledak.
  • Ketergantungan pada urutan event. Event bisa datang terlambat atau tertukar.

Debugging dan operasional: hal kecil yang sangat membantu

1. Selalu punya request ID

Kirim dan log Request-Id pada setiap respons. Jika client juga boleh mengirim correlation ID, log keduanya. Saat ada laporan “request timeout tapi order muncul dua kali”, Anda bisa menelusuri jejaknya lebih cepat.

2. Log keputusan idempotensi

Catat apakah request pertama kali diproses, menabrak key yang sama, atau ditolak karena payload berbeda. Ini sangat membantu saat debugging retry yang tidak konsisten.

3. Pisahkan error bisnis dan error infrastruktur

Gagal validasi kartu, stok habis, dan race condition bukan kategori yang sama dengan timeout database atau upstream 503. Pemisahan ini penting untuk alerting dan retry policy.

4. Ukur duplikasi dan retry

Minimal, pantau metrik seperti:

  • jumlah request dengan Idempotency-Key,
  • rasio hit idempotensi,
  • jumlah conflict akibat payload berbeda,
  • jumlah webhook retry,
  • jumlah event duplikat yang terdeteksi.

Checklist implementasi

  1. Tentukan resource dan action secara eksplisit; hindari endpoint serbaguna yang kabur.
  2. Pastikan shape respons sukses dan error konsisten di semua endpoint.
  3. Tambahkan Request-Id pada setiap respons.
  4. Dukung Idempotency-Key untuk POST yang membuat efek bisnis penting.
  5. Simpan request_hash, status proses, dan hasil respons pada record idempotensi.
  6. Gunakan mekanisme atomik untuk mencegah dua request paralel dengan key sama diproses dua kali.
  7. Tambahkan unique constraint pada business key yang memang harus unik.
  8. Dokumentasikan status code dan mana yang aman di-retry.
  9. Gunakan timeout yang masuk akal dan jangan menggabungkan terlalu banyak kerja berat di request sinkron.
  10. Jika proses lama, sediakan endpoint status dan pertimbangkan webhook.
  11. Desain webhook sebagai at least once delivery, dengan event ID dan signature.
  12. Dokumentasikan edge case: timeout setelah commit, duplicate webhook, event out-of-order, dan idempotency conflict.
  13. Siapkan kebijakan deprecation sebelum Anda benar-benar butuh versi baru.

Contoh dokumentasi edge case yang sebaiknya ditulis jelas

  • Timeout setelah request diterima: client harus retry dengan Idempotency-Key yang sama.
  • Payload berbeda dengan key sama: server mengembalikan 409 Conflict.
  • Webhook duplikat: penerima harus menganggap event dapat dikirim lebih dari sekali.
  • Status masih processing: client boleh polling endpoint resource.
  • Event datang tidak berurutan: client harus mengandalkan status resource terbaru, bukan urutan event semata.

Penutup

Untuk engineer yang membangun produk sendirian atau dengan tim kecil, API yang baik bukan API yang terlihat rumit, melainkan yang tetap bisa dipercaya saat sistem tidak berjalan mulus. Kontrak yang stabil mengurangi biaya perubahan. Idempotensi mencegah efek samping ganda saat retry tidak terhindarkan. Error schema yang konsisten membuat client lebih cerdas. Timeout, deduplikasi, dan webhook yang realistis membuat sistem tetap operasional tanpa perlu tim besar.

Jika harus memilih prioritas implementasi, mulailah dari tiga hal ini: error response konsisten, idempotency key untuk POST kritis, dan dokumentasi retry/edge case yang jelas. Tiga fondasi tersebut memberi dampak paling besar untuk menjaga API tetap stabil meski produk berkembang dan resource tetap terbatas.