Detail kecil API contract sering menjadi penyebab integrasi gagal, meskipun endpoint utama sudah berjalan dan dokumentasi terlihat lengkap. Masalahnya biasanya bukan pada fitur besar, melainkan pada hal-hal seperti apakah field yang hilang berarti null, bagaimana klien harus menangani enum baru, format error yang berubah-ubah, atau bagaimana server memberi tahu kapan aman untuk melakukan retry.

Kalau API Anda dipakai oleh tim lain, partner eksternal, aplikasi mobile, atau sistem yang sulit diperbarui cepat, detail ini menentukan apakah integrasi akan stabil atau rapuh. Artikel ini membahas panduan praktis untuk merancang API contract yang lebih tahan terhadap perubahan, lebih mudah di-debug, dan tidak merusak klien lama.

Mengapa detail kecil pada API contract sangat menentukan

API contract bukan hanya daftar endpoint dan field JSON. Contract adalah janji perilaku: bentuk request, bentuk response, aturan error, arti nilai kosong, perilaku retry, dan kompatibilitas saat API berevolusi. Jika bagian-bagian ini tidak eksplisit, setiap klien akan menebak sendiri. Dari sinilah bug integrasi muncul.

Beberapa kegagalan yang sangat umum:

  • Klien menganggap field selalu ada, padahal server kadang menghilangkannya.
  • Server menambah enum baru, lalu klien lama crash karena memakai switch tanpa default case.
  • Pagination berbasis offset menghasilkan data duplikat atau hilang saat dataset berubah.
  • Error 400, 409, dan 422 mengembalikan bentuk JSON berbeda sehingga handler klien menjadi bercabang-cabang.
  • Permintaan create di-retry oleh gateway, tetapi menghasilkan duplikasi karena tidak idempotent.
  • Timestamp dikirim tanpa zona waktu yang jelas, lalu terjadi perbedaan interpretasi antar sistem.
  • Webhook diterima tanpa verifikasi tanda tangan, sehingga endpoint rentan spoofing.

Tujuan desain contract yang baik bukan membuat API terlihat rapi di atas kertas, tetapi mengurangi ambiguitas saat sistem nyata gagal, melambat, di-retry, dan berevolusi.

1. Nullable vs omitted: bedakan “tidak ada”, “kosong”, dan “tidak diketahui”

Salah satu sumber bug paling sering adalah makna field yang tidak konsisten. Dalam JSON, ada perbedaan penting antara:

  • Field ada dengan nilai null
  • Field ada dengan string kosong ""
  • Field tidak ada sama sekali

Ketiganya tidak selalu setara. Jika API tidak menetapkan semantik yang jelas, klien akan salah menginterpretasi data.

Kapan memakai null

Gunakan null jika field tersebut memang bagian dari contract, tetapi nilainya saat ini belum ada atau tidak berlaku. Contoh: paid_at untuk invoice yang belum dibayar.

{
  "id": "inv_123",
  "status": "pending",
  "paid_at": null
}

Di sini paid_at tetap hadir karena field itu dikenal dan bermakna, hanya nilainya belum tersedia.

Kapan menghilangkan field

Hilangkan field jika:

  • Field tersebut bersifat opsional dan tidak diminta.
  • Field hanya tersedia untuk kondisi tertentu.
  • API menggunakan mekanisme partial response atau include.
{
  "id": "usr_123",
  "name": "Nadia"
}

Jika phone_number tidak ada sama sekali, dokumentasi harus menjelaskan apakah artinya “tidak disetel”, “tidak diminta”, atau “tidak diizinkan untuk caller ini”.

Aturan praktis yang aman

  • Untuk response, pilih konvensi yang konsisten per field dan dokumentasikan artinya.
  • Untuk PATCH/partial update, bedakan jelas antara field tidak dikirim dan field dikirim dengan null.
  • Jangan gunakan string kosong sebagai pengganti null jika semantik bisnisnya berbeda.

Contoh PATCH yang aman

Misalnya API profil pengguna mendukung update parsial:

PATCH /users/usr_123
Content-Type: application/json

{
  "display_name": "Nadia Putri",
  "phone_number": null
}

Interpretasi yang konsisten:

  • display_name dikirim: ubah nilainya.
  • phone_number: null: hapus nomor telepon.
  • Field yang tidak dikirim: jangan diubah.

Anti-pattern yang berbahaya adalah menganggap field tidak dikirim sama dengan null. Itu sering membuat update parsial tidak sengaja menghapus data.

2. Enum yang mudah diperluas tanpa merusak klien

Enum tampak sederhana, tetapi sering menjadi titik patah saat API berkembang. Hari ini status hanya pending, paid, failed. Besok mungkin muncul refunded, expired, atau requires_action.

Prinsip utama

  • Klien harus menganggap enum sebagai open set, bukan daftar tertutup permanen.
  • Server boleh menambah nilai enum baru tanpa merusak kompatibilitas mundur, selama semantik dasarnya masih masuk akal.
  • Dokumentasi harus menyebutkan bahwa klien wajib menangani nilai tak dikenal.

Contoh response

{
  "id": "pay_123",
  "status": "requires_action"
}

Jika klien lama hanya mengenal pending dan paid, ia seharusnya tidak crash. Minimal, tampilkan status generik “unknown” dan hindari keputusan fatal seperti menganggap transaksi pasti gagal.

Saran implementasi

  • Di sisi server, hindari mengganti arti enum lama.
  • Lebih aman menambah nilai baru daripada mengubah makna nilai yang sudah ada.
  • Jika perlu perilaku yang lebih kaya, pertimbangkan memisahkan status utama dan status_reason.
{
  "id": "pay_123",
  "status": "pending",
  "status_reason": "awaiting_customer_confirmation"
}

Pemisahan ini membantu menjaga enum utama tetap stabil, sementara detail tambahan bisa berkembang lebih bebas.

3. Pagination token lebih aman daripada offset untuk data yang berubah

Offset-based pagination seperti ?page=2&per_page=20 mudah dipahami, tetapi bermasalah saat data berubah di antara request. Jika ada record baru masuk atau record lama terhapus, klien bisa melihat item duplikat atau melewatkan item tertentu.

Masalah offset

GET /orders?page=2&per_page=2

Jika setelah halaman pertama diambil ada order baru masuk ke posisi teratas, isi halaman kedua bisa bergeser. Ini buruk untuk sinkronisasi, ekspor, dan pemrosesan batch.

Gunakan pagination token/cursor

Token biasanya merepresentasikan posisi relatif terhadap urutan yang stabil, misalnya kombinasi created_at dan id. Klien tidak perlu memahami isi token; cukup kirim kembali token itu pada request berikutnya.

GET /orders?limit=2

{
  "data": [
    { "id": "ord_101", "created_at": "2025-01-10T10:00:00Z" },
    { "id": "ord_100", "created_at": "2025-01-10T09:58:00Z" }
  ],
  "next_page_token": "eyJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xMFQwOTo1ODowMFoiLCJpZCI6Im9yZF8xMDAifQ"
}
GET /orders?limit=2&page_token=eyJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xMFQwOTo1ODowMFoiLCJpZCI6Im9yZF8xMDAifQ

Kenapa ini bekerja

Dengan urutan yang stabil, server dapat melanjutkan dari titik terakhir yang sudah terlihat klien. Hasilnya lebih konsisten untuk feed yang terus berubah.

Trade-off

  • Cursor/token lebih sulit di-debug manual dibanding page number.
  • Tidak selalu cocok untuk kebutuhan “lompat ke halaman 37”.
  • Anda harus menentukan sort order yang stabil dan terdokumentasi.

Jika tetap memakai offset untuk daftar admin sederhana, itu masih masuk akal. Tetapi untuk integrasi sinkronisasi dan data yang sering berubah, pagination token biasanya pilihan lebih aman.

4. Format error harus konsisten di semua endpoint

Klien tidak hanya butuh tahu bahwa request gagal, tetapi juga kenapa, di bagian mana, dan apa yang bisa dilakukan. Masalah muncul ketika setiap endpoint mengembalikan bentuk error berbeda.

Elemen minimal yang sebaiknya selalu ada

  • error.code: kode mesin yang stabil
  • error.message: pesan yang bisa dibaca manusia
  • error.details: rincian per field atau konteks tambahan
  • request_id: identitas request untuk debugging

Contoh format error yang konsisten

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Request-Id: req_7f29b1

{
  "error": {
    "code": "validation_failed",
    "message": "Beberapa field tidak valid.",
    "details": [
      {
        "field": "email",
        "reason": "invalid_format"
      },
      {
        "field": "birth_date",
        "reason": "must_be_past_date"
      }
    ]
  },
  "request_id": "req_7f29b1"
}

Prinsip penting

  • Jangan jadikan message sebagai satu-satunya sumber logika klien. Pesan bisa berubah, diterjemahkan, atau dipersingkat.
  • Gunakan code yang stabil untuk otomasi.
  • Pastikan struktur yang sama dipakai untuk 400, 401, 403, 404, 409, 422, 429, dan 5xx sebisa mungkin.

Status code yang sering tertukar

  • 400: request tidak valid secara umum atau malformed.
  • 401: belum terautentikasi.
  • 403: terautentikasi tetapi tidak berhak.
  • 404: resource tidak ditemukan atau sengaja disamarkan.
  • 409: konflik state, misalnya versi data bentrok atau duplicate operation.
  • 422: payload valid secara sintaks, tetapi gagal aturan domain/validasi.
  • 429: terlalu banyak request, biasanya perlu info retry.

Konsistensi di sini sangat mengurangi kode khusus di sisi klien.

5. Idempotency-Key untuk operasi create dan retry yang aman

Pada sistem nyata, request bisa diulang oleh klien, load balancer, mobile app saat jaringan putus, atau job worker saat timeout. Jika operasi seperti POST /payments tidak idempotent, hasilnya bisa duplikat: pembayaran dobel, order ganda, atau ticket tercetak dua kali.

Gunakan header Idempotency-Key

Klien mengirim nilai unik per operasi logis. Server menyimpan hasil request pertama untuk kombinasi tertentu, lalu mengembalikan hasil yang sama jika request identik datang lagi dengan key yang sama.

POST /payments
Content-Type: application/json
Idempotency-Key: 8db7f0c4-7480-4f7e-bf6d-8e5ecb24d34e

{
  "order_id": "ord_9001",
  "amount": 150000,
  "currency": "IDR"
}
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "pay_123",
  "status": "pending"
}

Jika request yang sama dikirim ulang karena timeout, server sebaiknya mengembalikan response yang konsisten, bukan membuat pembayaran baru.

Hal yang perlu ditetapkan di contract

  • Endpoint mana yang mendukung Idempotency-Key.
  • Berapa lama key disimpan.
  • Apakah payload harus identik untuk key yang sama.
  • Respons apa yang dikembalikan jika key sama dipakai dengan payload berbeda.

Pola aman di server

  • Simpan status request berdasarkan idempotency key.
  • Ikat key ke konteks yang relevan, misalnya akun atau merchant, agar tidak bentrok lintas tenant.
  • Jika key sama dipakai dengan body berbeda, kembalikan konflik, misalnya 409.

Tanpa aturan ini, retry otomatis justru menghasilkan inkonsistensi data.

6. Retry-After: beri sinyal kapan klien boleh mencoba lagi

Banyak API mengembalikan 429 Too Many Requests atau 503 Service Unavailable, tetapi tidak memberi petunjuk kapan klien sebaiknya retry. Akibatnya klien menebak sendiri, sering kali terlalu agresif dan memperburuk beban sistem.

Gunakan header Retry-After

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60

{
  "error": {
    "code": "rate_limited",
    "message": "Terlalu banyak request. Coba lagi nanti."
  }
}

Retry-After: 60 berarti klien menunggu 60 detik sebelum mencoba lagi. Pada kondisi tertentu, header ini juga bisa berupa tanggal HTTP, tetapi angka detik biasanya lebih sederhana untuk integrasi.

Kenapa penting

  • Mengurangi retry liar dari klien.
  • Membantu SDK, worker, dan integrator membuat strategi backoff yang benar.
  • Membuat perilaku throttling lebih dapat diprediksi.

Saran implementasi

  • Kombinasikan Retry-After dengan exponential backoff dan jitter di sisi klien.
  • Jangan kirim Retry-After asal-asalan jika server sebenarnya belum siap pada waktu itu.
  • Bedakan error yang aman di-retry dan yang tidak. Misalnya validasi 422 tidak perlu retry otomatis.

7. Timestamp dan timezone: selalu eksplisit

Timestamp tanpa zona waktu adalah undangan untuk bug lintas layanan. Format seperti 2025-01-10 10:00:00 tampak jelas bagi manusia, tetapi ambigu bagi mesin: ini UTC, WIB, zona server, atau lokal pengguna?

Format yang aman

Gunakan timestamp ISO 8601 dengan offset atau UTC eksplisit, misalnya:

  • 2025-01-10T10:00:00Z
  • 2025-01-10T17:00:00+07:00

Untuk API antar sistem, UTC dengan akhiran Z biasanya paling mudah diproses secara konsisten.

Contoh response

{
  "id": "evt_123",
  "created_at": "2025-01-10T10:00:00Z",
  "expires_at": "2025-01-10T10:15:00Z"
}

Aturan yang sebaiknya tegas

  • Tentukan apakah semua timestamp selalu UTC.
  • Jangan campur format tanggal antar endpoint.
  • Jika menerima input waktu lokal, sertakan timezone atau offset yang jelas.
  • Bedakan tipe date-only dan datetime.

Misalnya birth_date sebaiknya berupa tanggal tanpa jam jika memang tidak butuh waktu:

{
  "birth_date": "1998-04-21"
}

Jangan memaksakan datetime untuk nilai yang secara domain hanya berupa tanggal, karena itu memicu kebingungan zona waktu yang tidak perlu.

8. Webhook signature: jangan percaya request masuk begitu saja

Webhook memindahkan kontrol dari polling ke push, tetapi juga membuka risiko keamanan. Endpoint webhook Anda akan menerima HTTP request dari internet; tanpa verifikasi asal dan integritas payload, siapa pun bisa mengirim event palsu.

Prinsip verifikasi

  • Provider webhook menandatangani payload dengan secret bersama.
  • Consumer menghitung ulang signature dari body mentah request.
  • Bandingkan hasilnya dengan header signature menggunakan perbandingan yang tahan timing attack.

Contoh header dan body

POST /webhooks/payment
Content-Type: application/json
X-Signature: sha256=4f4c3d...
X-Timestamp: 1736503200

{
  "id": "evt_123",
  "type": "payment.updated",
  "created_at": "2025-01-10T10:00:00Z",
  "data": {
    "payment_id": "pay_123",
    "status": "paid"
  }
}

Yang sering salah

  • Menghitung signature dari JSON yang sudah diparsing ulang, bukan dari raw body.
  • Tidak memeriksa timestamp, sehingga replay attack lebih mudah.
  • Tidak mendokumentasikan algoritma, format string yang ditandatangani, dan header yang dipakai.

Saran contract untuk webhook

  • Dokumentasikan algoritma signature secara spesifik.
  • Jelaskan apakah signature dihitung dari raw body saja atau gabungan timestamp + body.
  • Sertakan toleransi waktu untuk mencegah replay.
  • Jelaskan respons sukses dan retry policy provider webhook.

Selain verifikasi signature, desain webhook juga sebaiknya idempotent. Event yang sama bisa terkirim lebih dari sekali, jadi consumer perlu memeriksa event_id atau identifier lain untuk deduplikasi.

Contoh contract yang lebih tahan integrasi

Berikut contoh endpoint pembuatan invoice dengan beberapa detail contract yang sering menyelamatkan integrasi.

POST /invoices
Content-Type: application/json
Idempotency-Key: 5bca2f88-2c58-4a68-a9ea-c4fd6a091e03

{
  "customer_id": "cus_123",
  "currency": "IDR",
  "due_at": "2025-01-15T10:00:00Z",
  "notes": null,
  "line_items": [
    {
      "sku": "PRD-001",
      "name": "Langganan Pro",
      "quantity": 1,
      "unit_price": 150000
    }
  ]
}
HTTP/1.1 201 Created
Content-Type: application/json
X-Request-Id: req_abc123

{
  "id": "inv_123",
  "status": "pending",
  "currency": "IDR",
  "due_at": "2025-01-15T10:00:00Z",
  "paid_at": null,
  "created_at": "2025-01-10T10:00:00Z",
  "request_id": "req_abc123"
}

Hal baik dari contoh ini:

  • notes: null punya makna jelas.
  • Timestamp konsisten dan eksplisit UTC.
  • Idempotency-Key mendukung retry aman.
  • status bisa berkembang selama klien menangani unknown value.
  • request_id membantu investigasi log.

Anti-pattern yang sering merusak integrasi

  • Mengubah arti field lama tanpa mengganti nama atau memberi periode transisi.
  • Mengubah tipe data dari number ke string atau sebaliknya pada field yang sama.
  • Mengirim timestamp lokal server tanpa offset atau zona waktu.
  • Mencampur format error antar endpoint.
  • Menambah enum baru tanpa dokumentasi bahwa nilai tak dikenal harus ditangani.
  • Menggunakan offset pagination untuk feed yang sangat dinamis tanpa menjelaskan risiko konsistensi.
  • Tidak menyediakan idempotency pada endpoint create yang mungkin di-retry.
  • Mengandalkan pesan error bebas sebagai kontrak mesin.
  • Webhook tanpa verifikasi signature atau tanpa deduplikasi event.
  • Memperlakukan field omitted sama dengan null pada partial update.

Kompatibilitas mundur agar klien lama tidak rusak

Kompatibilitas mundur bukan berarti API tidak boleh berubah. Artinya perubahan dilakukan dengan cara yang masih bisa ditoleransi oleh klien lama.

Perubahan yang biasanya aman

  • Menambah field baru pada response, selama klien diharapkan mengabaikan field tak dikenal.
  • Menambah nilai enum baru, jika klien sudah didorong untuk menangani unknown value.
  • Menambah endpoint baru atau parameter opsional baru.

Perubahan yang berisiko tinggi

  • Menghapus field existing.
  • Mengubah field dari nullable menjadi selalu wajib, atau sebaliknya, tanpa transisi.
  • Mengganti format tanggal, tipe data, atau kode error.
  • Mengubah urutan semantik pagination tanpa dokumentasi dan migrasi.

Saran migrasi yang lebih aman

  • Gunakan periode deprecation yang jelas.
  • Tambahkan field baru dulu, pertahankan field lama sementara.
  • Jika perlu perubahan besar, pertimbangkan versi endpoint atau versi media type.
  • Monitor penggunaan field lama sebelum benar-benar dihapus.
  • Sertakan changelog yang fokus pada dampak integrasi, bukan hanya daftar fitur.

Catatan: Menambah field response sering dianggap aman, tetapi bisa tetap merusak klien yang melakukan parsing terlalu ketat. Karena itu, selain mendesain API dengan baik, dorong implementasi klien yang toleran terhadap field tambahan.

Checklist review API sebelum rilis

  1. Apakah setiap field punya arti yang jelas saat bernilai null, kosong, atau tidak ada?
  2. Apakah partial update membedakan field omitted dan field yang sengaja dihapus?
  3. Apakah enum didokumentasikan sebagai nilai yang bisa bertambah?
  4. Apakah pagination memakai urutan yang stabil dan cocok untuk use case sinkronisasi?
  5. Apakah semua error memakai struktur JSON yang konsisten?
  6. Apakah ada request_id atau korelasi serupa untuk debugging?
  7. Apakah endpoint create yang rawan retry mendukung Idempotency-Key?
  8. Apakah respons 429/503 memberi Retry-After yang masuk akal?
  9. Apakah semua timestamp eksplisit timezone-nya?
  10. Apakah date-only dan datetime dibedakan?
  11. Apakah webhook punya signature, proteksi replay, dan aturan retry?
  12. Apakah perubahan terbaru aman bagi klien lama?
  13. Apakah contoh request/response di dokumentasi sama dengan perilaku aktual?
  14. Apakah ada test contract atau integration test lintas service untuk memverifikasi semua aturan ini?

Tips debugging saat integrasi mulai bermasalah

  • Bandingkan raw HTTP request/response, bukan hanya objek yang sudah diparsing SDK.
  • Catat header penting: X-Request-Id, Idempotency-Key, Retry-After, dan header signature webhook.
  • Periksa apakah field hilang, bernilai null, atau hanya kosong.
  • Validasi parsing timestamp dan zona waktu di kedua sisi.
  • Simulasikan retry dan duplicate delivery, terutama untuk payment, order, dan webhook.
  • Uji bagaimana klien bereaksi terhadap enum baru dan field tambahan.

Penutup

Detail kecil API contract yang mencegah integrasi gagal bukan kosmetik dokumentasi, melainkan keputusan desain yang memengaruhi ketahanan sistem saat menghadapi retry, perubahan data, evolusi fitur, dan kegagalan jaringan. Jika Anda memperjelas semantik null vs omitted, merancang enum yang mudah diperluas, memakai pagination token untuk data dinamis, menstandarkan format error, mendukung Idempotency-Key, mengirim Retry-After, menegaskan timezone, dan mengamankan webhook dengan signature, peluang integrasi gagal turun drastis.

API yang baik biasanya tidak terlihat spektakuler. Ia terasa membosankan, dapat diprediksi, dan sulit disalahpahami. Justru itu tanda bahwa contract-nya dirancang dengan benar.