Pencarian Pelanggan
Referensi endpoint POST /api/customer_lookup.php — cari pelanggan lewat satu nomor telepon, dapatkan riwayat pesanan singkat dan pesan pembuka siap kirim. Body, respons, kode galat, dan batas laju.
Endpoint ini menjawab satu pertanyaan: "nomor yang barusan chat ini siapa, dan pesanan apa yang sedang berjalan?"
Dipakai konektor CRM dan aplikasi CS. Satu permintaan mencari satu nomor telepon secara persis. Tidak ada pencarian sebagian, tidak ada daftar, tidak ada halaman berikutnya.
#Ringkasan
| Item | Nilai |
|---|---|
| URL produksi | https://adform.id/api/customer_lookup.php |
| URL staging | https://staging.adform.id/api/customer_lookup.php |
| Method | POST saja. Method lain dibalas 405. |
| Autentikasi | Header X-API-Key: adk_… |
| Scope | customers:read |
| Content-Type permintaan | application/json |
| Ukuran body maksimum | 4 KB |
| Batas laju | 30 / menit, 600 / jam, 4.000 / hari — per API key |
| Waktu jawab minimum | 150 milidetik, disengaja |
#Kenapa POST, bukan GET
Nomor telepon pelanggan tidak boleh masuk query string. Query string tercatat di log akses server pada setiap panggilan, dan log itu berputar serta ikut tersalin ke cadangan. Dalam sebulan, log berisi ribuan nomor pelanggan dalam bentuk terbaca, di tempat yang tidak diaudit sebagai penyimpanan data pribadi.
Body POST tidak dicatat. Karena itu GET dibalas 405 dan tidak akan pernah dilonggarkan. Ini bukan preferensi gaya REST.
Konsekuensi untuk Anda: endpoint ini tidak bisa di-cache oleh proxy, dan tidak bisa dipanggil dari browser. Panggil dari server Anda.
#Autentikasi
Satu header:
X-API-Key: adk_0123456789abcdef0123456789abcdef0123456789abcdefKey harus memuat scope customers:read. Key ber-scope * juga lolos, tetapi jangan meminta tenant membuat key seperti itu untuk konektor CS — mintalah customers:read saja.
Cara tenant memperoleh key ada di Autentikasi. Satu key terikat ke satu tenant; konteks tenant ditentukan sepenuhnya oleh key dan tidak bisa Anda kirim sendiri.
Endpoint ini menolak pemanggilan lintas-asal dari browser. Header Access-Control-Allow-Origin yang dikirimnya bernilai null, dan X-API-Key tidak ada di daftar header yang diizinkan preflight. Ini server-ke-server.
#Body permintaan
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
phone | string | wajib | Nomor telepon pelanggan. Format bebas: 08…, 62…, +62…, boleh berisi spasi atau tanda hubung. Kami normalkan sendiri dan mencocokkan semua variannya. |
orders | integer | opsional | Berapa pesanan terakhir yang ikut dikembalikan. Rentang 1–5, bawaan 3. Nilai di luar rentang dipangkas ke batas terdekat, bukan ditolak. |
Field lain diabaikan diam-diam, bukan ditolak. Tidak ada q, limit, page, atau offset.
#Contoh permintaan
curl -sS -X POST 'https://adform.id/api/customer_lookup.php' \
-H 'X-API-Key: adk_0123456789abcdef0123456789abcdef0123456789abcdef' \
-H 'Content-Type: application/json' \
-d '{"phone":"081234567890","orders":3}'Contoh minimum, tanpa orders:
curl -sS -X POST 'https://adform.id/api/customer_lookup.php' \
-H 'X-API-Key: adk_0123456789abcdef0123456789abcdef0123456789abcdef' \
-H 'Content-Type: application/json' \
-d '{"phone":"+62 812-3456-7890"}'#Respons — pelanggan ditemukan (200)
{
"found": true,
"customer": {
"name": "Budi Santoso",
"phone": "6281234567890",
"city": "Bandung",
"province": "Jawa Barat",
"orders_total": 4,
"orders_paid": 3,
"lifetime_value": 742000,
"first_order_at": "2026-03-11 08:22:41",
"last_order_at": "2026-07-27 09:41:11"
},
"recent_orders": [
{
"id": 48211,
"product": "Kemeja Linen Pria Lengan Panjang",
"variant": "Navy / L",
"quantity": 2,
"total": 189000,
"shipping_fee": 24000,
"payment_method": "COD",
"status": "pending",
"courier": "JNE REG",
"tracking_number": null,
"created_at": "2026-07-27 09:41:11"
},
{
"id": 47903,
"product": "Celana Chino Slim Fit",
"variant": "Khaki / 32",
"quantity": 1,
"total": 235000,
"shipping_fee": 20000,
"payment_method": "QRIS",
"status": "completed",
"courier": "SiCepat BEST",
"tracking_number": "005412998877",
"created_at": "2026-06-30 19:05:12"
}
],
"conversation": {
"exists": true,
"status": "open",
"last_at": "2026-07-27 10:03:55"
},
"welcome": {
"message": "Halo Kak Budi Santoso 😊\n\nTerima kasih sudah order di toko kami.\nBerikut rincian pesanan Kakak:\n\nProduk: Kemeja Linen Pria Lengan Panjang Navy / L\nOngkir: JNE Rp 24.000\nTotal: *Rp 189.000* - COD (Bayar di Tempat)\n\nMohon pastikan alamat di atas sudah lengkap dan benar.\nAlamat yang lengkap akan mempermudah kurir dan mempercepat pengiriman paket.\n\nPesanan akan segera kami proses.\nTerima kasih 🙏",
"template": "default:fu_welcome_cod"
},
"meta": {
"request_id": "9f3c1a7b",
"matched_variants": 2
}
}#Respons — tidak ditemukan (200)
Bentuknya sama dan kode statusnya juga 200. Kode status sengaja tidak dibedakan supaya tidak bisa dipakai sebagai penanda murah "nomor ini terdaftar atau tidak".
{
"found": false,
"customer": null,
"recent_orders": [],
"conversation": {
"exists": false
},
"welcome": {
"message": null,
"template": null
},
"meta": {
"request_id": "2b8d0e14",
"matched_variants": 0
}
}Perhatikan tiga hal:
conversationdiratakan jadi{"exists": false}saja. Nomor yang pernah berkirim chat tetapi belum pernah memesan tidak bisa dibedakan dari nomor yang sama sekali asing.- Blok
welcomediratakan jadi duanull.reasontidak pernah terisi saatfoundbernilaifalse— kalau ia terisi di situ, ia jadi penanda keanggotaan yang persis dihindari. - Waktu jawabnya sama dengan jalur "ketemu". Jangan mencoba menyimpulkan apa pun dari lamanya respons.
#Tabel field respons
| Field | Tipe | Keterangan |
|---|---|---|
found | boolean | true kalau ada minimal satu pesanan dengan nomor itu. |
customer | object | null | null saat found bernilai false. |
customer.name | string | null | Nama dari pesanan terbaru. |
customer.phone | string | Nomor ternormalisasi, format 62, tanpa + dan tanpa spasi. |
customer.city | string | null | Kota dari pesanan terbaru. |
customer.province | string | null | Provinsi dari pesanan terbaru. |
customer.orders_total | integer | Jumlah pesanan, dihitung dari maksimal 100 pesanan terbaru. |
customer.orders_paid | integer | Jumlah pesanan yang uangnya sudah masuk. |
customer.lifetime_value | integer | Rupiah dari pesanan yang uangnya sudah masuk. Bukan total keranjang. |
customer.first_order_at | string | null | Pesanan terlama di antara 100 pesanan terbaru, bukan pesanan pertama seumur hidup. |
customer.last_order_at | string | null | Waktu pesanan terbaru. |
recent_orders | array | Sebanyak-banyaknya nilai orders yang diminta, urut dari terbaru. Kosong saat found bernilai false. |
recent_orders[].id | integer | ID pesanan di AdForm. Unik per tenant, bukan lintas tenant. |
recent_orders[].product | string | null | Nama produk. |
recent_orders[].variant | string | null | Varian produk. |
recent_orders[].quantity | integer | Minimal 1. |
recent_orders[].total | integer | Total tagihan rupiah penuh, sudah termasuk ongkir. |
recent_orders[].shipping_fee | integer | Ongkir. 0 kalau tidak ada. |
recent_orders[].payment_method | string | null | Contoh: COD, QRIS, Transfer Bank. |
recent_orders[].status | string | null | Nilai status mentah AdForm. Satu kolom ini mencampur status bayar dan tahap kirim; tidak ada field status pengiriman terpisah. Perlakukan sebagai string bebas, jangan enum keras. |
recent_orders[].courier | string | null | Nama kurir + layanannya digabung, mis. "JNE REG". |
recent_orders[].tracking_number | string | null | Nomor resi. null saat pesanan belum dikirim. |
recent_orders[].created_at | string | null | Waktu pesanan dibuat. |
conversation.exists | boolean | Ada tidaknya riwayat percakapan CS di AdForm untuk nomor itu. Selalu false saat found bernilai false. |
conversation.status | string | null | Hanya ada saat exists bernilai true. |
conversation.last_at | string | null | Hanya ada saat exists bernilai true. |
welcome.message | string | null | Pesan pembuka siap kirim. Lihat bagian berikutnya. |
welcome.template | string | null | Penanda asal template, bentuknya "<lapisan>:<kunci>", mis. "settings:fu_welcome_cod". Lapisan menjawab "diedit di mana", kunci menjawab "yang mana". Sertakan saat melapor keluhan "isi pesannya salah". |
welcome.reason | string | null | Alasan pesan tidak diberikan. Terisi hanya saat found bernilai true dan welcome.message bernilai null. Tidak pernah terisi saat found bernilai false — kalau terisi di situ, ia jadi penanda keanggotaan. Lihat tabel di bawah. |
welcome.warning | string | null | Kebalikan dari reason: hanya ada saat pesannya diserahkan, sebagai catatan setelan penjual yang belum lengkap. Satu nilai hari ini: rekening_belum_diatur. Pesannya tetap harus dikirim apa adanya. |
meta.request_id | string | 8 karakter heksadesimal. Sertakan saat melapor masalah ke kami. |
meta.matched_variants | integer | Berapa varian format nomor yang cocok di basis data. |
meta.degraded | boolean | Hanya muncul kalau pencarian pesanan gagal di sisi kami. Kalau ada, jangan simpulkan pelanggan tidak terdaftar — coba lagi nanti. |
#Blok welcome — pesan pembuka siap kirim
welcome.message bukan data, melainkan kalimat jadi. Kirim apa adanya sebagai balasan pertama ke pelanggan.
Isinya dikarang penjual di dashboard AdForm (Pengaturan → Follow-Up), bukan ditulis di sistem Anda. Tanpa blok ini, konektor tidak punya kalimat apa pun untuk dikirim.
Tiga hal yang mudah salah:
\nadalah baris baru sungguhan setelah JSON di-parse. Kirim sebagai baris baru, bukan sebagai dua karakter\dann.- Ada spasi tanpa-putus (U+00A0) di antara
Rpdan angkanya, hasil pemformatan rupiah. Jangan menormalkan spasi di dalam pesan — kalau diganti spasi biasa, teks yang diterima pelanggan berbeda dari yang dilihat penjual di dashboard. - Jangan mem-parse nomor telepon dari dalam teks pesan. Pakai
customer.phone.
#Kapan welcome.message bernilai null
Ini yang paling sering disalahartikan sebagai kerusakan. Ia bukan kerusakan — ia gerbang yang disengaja.
| Kondisi | found | welcome.reason | Yang harus dilakukan konektor |
|---|---|---|---|
| Nomor tidak ditemukan | false | kunci tidak ada | Tidak ada pesan pembuka. Tangani sebagai pelanggan baru. |
| Pesanan terbarunya lebih tua dari 48 jam | true | terlalu_lama | Jangan mengirim pesan pembuka. Pelanggan yang memesan berbulan-bulan lalu lalu menghubungi untuk urusan lain akan tersinggung menerima "terima kasih sudah order, silakan bayar". |
| Pesanan terbarunya sudah lunas, atau dibatalkan | true | sudah_bayar | Jangan mengirim. Pesan pembuka bawaan menyuruh transfer. |
Pesan untuk pesanan itu sudah pernah diserahkan — lewat endpoint ini, lewat panggilan lain yang datang bersamaan, atau lewat kiriman webhook order.created yang benar-benar membawa kalimatnya | true | sudah_dikirim | Jangan mengirim. Sekali serah, tidak diulang. Ini yang mencegah pelanggan menerima sapaan yang sama dua kali — termasuk saat ia mengirim beberapa pesan beruntun dan Anda memanggil endpoint ini beberapa kali dalam sedetik. |
| Perender pesan gagal di sisi kami | true | null | Data pelanggannya tetap benar dan tetap berguna. Balas dengan kalimat Anda sendiri. |
welcome.template boleh terisi walau welcome.message bernilai null — itu berarti template-nya ketemu tetapi hasil rendernya kosong.
Nomor rekening penjual yang belum diisi tidak ada di tabel ini lagi. Dulu keadaan itu membuat welcome.message bernilai null dengan reason: "rekening_belum_diatur". Sekarang pesannya tetap diserahkan: di tempat nomor rekening ada kalimat "(Nomor rekeningnya kami kirimkan menyusul lewat chat ini ya Kak.)", dan jawabannya membawa key baru welcome.warning bernilai rekening_belum_diatur. Alasannya: metode bayar bawaan di AdForm adalah transfer, jadi aturan lama ikut membungkam toko COD dan toko produk digital yang memang tidak butuh nomor rekening — pembelinya tidak disapa sama sekali, untuk setiap pesanan.
warning bukan reason. Kirim pesannya apa adanya. Key itu untuk panel operator Anda, supaya setelan penjual yang belum lengkap kelihatan — bukan perintah menahan pesan. Nilainya bisa bertambah; nilai yang tidak dikenal diperlakukan sama: tampilkan, jangan bercabang.
{
"found": true,
"welcome": {
"message": "Halo Kak Budi Santoso 😊\n…",
"template": "default:fu_welcome_transfer",
"warning": "rekening_belum_diatur"
}
}Contoh respons yang pelanggannya ketemu tetapi pesan pembukanya digerbangi — pesanan terakhirnya tiga bulan lalu:
{
"found": true,
"customer": {
"name": "Siti Nurhaliza",
"phone": "6285712345678",
"city": "Malang",
"province": "Jawa Timur",
"orders_total": 2,
"orders_paid": 2,
"lifetime_value": 510000,
"first_order_at": "2026-02-14 11:07:20",
"last_order_at": "2026-04-02 16:40:55"
},
"recent_orders": [
{
"id": 41880,
"product": "Paket Kopi Gayo 500gr",
"variant": "Halus",
"quantity": 1,
"total": 275000,
"shipping_fee": 20000,
"payment_method": "QRIS",
"status": "completed",
"courier": "SiCepat BEST",
"tracking_number": "005412998877",
"created_at": "2026-04-02 16:40:55"
}
],
"conversation": {
"exists": true,
"status": "open",
"last_at": "2026-07-27 10:11:02"
},
"welcome": {
"message": null,
"template": "default:fu_welcome_transfer",
"reason": "terlalu_lama"
},
"meta": {
"request_id": "c41e77a0",
"matched_variants": 1
}
}Yang benar dilakukan konektor di sini: pakai data pelanggannya, jangan kirim pesan pembukanya. Balas dengan kalimat CS biasa.
#Alamat pelanggan tidak ada di sini
welcome.message yang dikembalikan endpoint ini sudah dibersihkan dari alamat. Template bawaan memuat penanda alamat; pada jalur ini penanda itu dirender jadi kosong, lalu seluruh blok alamatnya dibuang — bukan disisakan sebagai baris kosong. Bandingkan contoh di atas dengan contoh order.created di Webhook Keluar: pesanan yang sama, template yang sama, dan satu-satunya beda adalah blok alamat yang hilang di sini.
Yang ikut hilang bersama baris alamatnya, pada template bawaan: judul blok Alamat Pengiriman: beserta nama dan nomor di bawahnya.
Aturannya: baris yang isinya tinggal penanda alamat dibuang, judul blok di paragraf yang sama dengan penanda itu ikut dibuang, dan paragraf yang kehilangan baris judulnya dibuang seluruhnya. Baris di paragraf LAIN tidak pernah tersentuh, meski ia menyebut kata "alamat".
Berubah 2026-07-28 — dua kalimat bawaan sekarang BERTAHAN. Kalimat "Mohon pastikan alamat di atas sudah lengkap dan benar." dan "Alamat yang lengkap akan mempermudah kurir dan mempercepat pengiriman paket." ada di paragraf tersendiri, terpisah dari blok alamat oleh satu baris kosong. Dulu keduanya ikut dibuang karena menyebut kata "alamat". Aturan itu terlalu lebar: ia juga membuang kalimat penjual yang cuma kebetulan memakai kata yang sama — mis. "File-nya kami kirim ke alamat email yang Kakak isi di form." pada toko produk digital, yang hilangnya membuat pembeli tidak pernah diberi tahu ke mana filenya dikirim. Penyapu sekarang hanya bekerja di dalam paragraf yang memuat penandanya. Akibatnya: pesan di jalur ini bisa memuat kalimat "Mohon pastikan alamat di atas sudah lengkap dan benar." tanpa ada alamat di atasnya. Itu disengaja dan bukan tanda pesan terpotong — kirim apa adanya, jangan disaring.
Sisa pesannya utuh: sapaan, produk, varian, ongkir, total, metode bayar, dan — pada cabang transfer — nomor rekening beserta perintah mengirim bukti pembayaran. Template buatan penjual diperlakukan dengan aturan yang sama.
Konsekuensi yang perlu disadari konektor: pada jalur ini alamatnya sendiri tidak ditampilkan. Kalau penjual keberatan, sarankan ia mengubah template Welcome di dashboard AdForm — jangan menambahkan alamat dari sisi konektor.
Alasannya sama dengan alasan blok customer hanya memuat kota dan provinsi:
Yang sengaja tidak dikirim: alamat lengkap, kelurahan, kecamatan, kode pos, dan email. Field itu tidak menjawab satu pun pertanyaan CS, sementara gabungan nama + nomor + alamat rumah adalah paket data yang berbahaya kalau API key bocor.
Alamat lengkap tetap dikirim lewat Webhook Keluar, di field data.welcome_message dan data.customer.address_full. Bedanya di sana: tenant memilih sendiri alamat tujuannya per endpoint, tujuan itu harus lolos verifikasi kepemilikan, dan datanya didorong hanya untuk pesanan yang memang baru terjadi — bukan bisa ditarik per nomor sesuka penarik.
Kalau produk Anda benar-benar butuh alamat lewat jalur tarik, hubungi kami. Kami akan menambah scope terpisah, bukan melonggarkan customers:read.
#Tabel kode galat
| Kode | Body | Sebab | Tindakan konektor |
|---|---|---|---|
400 | {"error":"phone wajib diisi"} | Field phone tidak ada, bukan nilai skalar, atau hanya spasi. | Perbaiki permintaan. Jangan ulangi. |
400 | {"error":"phone tidak valid"} | Setelah dibersihkan, nomornya tidak menghasilkan bentuk yang bisa dicocokkan. | Jangan ulangi. |
401 | {"error":"API key required (X-API-Key header)"} | Header tidak dikirim atau kosong. | Jangan ulangi. Periksa apakah proxy Anda membuang header kustom. |
401 | {"error":"Malformed API key"} | Bentuk bukan adk_ + 48 heksadesimal huruf kecil. | Jangan ulangi. |
401 | {"error":"Invalid API key"} | Bentuknya benar tetapi tidak dikenal. | Jangan ulangi. Pastikan Anda memakai key untuk lingkungan yang benar. |
401 | {"error":"API key revoked"} | Key sudah dicabut. | Minta tenant membuat key baru. |
401 | {"error":"API key expired"} | Key punya tanggal kedaluwarsa dan sudah lewat. | Minta tenant memperpanjang. |
403 | {"error":"Missing scope: customers:read"} | Key valid tetapi tidak memuat scope ini. | Minta tenant membuat key dengan scope customers:read. |
403 | {"error":"Tenant inactive"} | Akun tenant pemilik key sedang nonaktif. | Hentikan pemanggilan sampai tenant aktif lagi. |
405 | {"error":"Method not allowed"} | Method bukan POST. | Perbaiki permintaan. |
413 | {"error":"Payload too large"} | Body lebih dari 4 KB. | Perbaiki permintaan. |
429 | {"error":"Terlalu banyak permintaan. Coba lagi nanti."} | Batas laju terlampaui. | Tunggu selama Retry-After detik, lalu ulangi dengan jeda yang membesar. |
Hanya 429 yang layak diulang otomatis. Sisanya kesalahan konfigurasi atau kesalahan permintaan.
Tidak ada 500 untuk kegagalan basis data. Kalau pencarian pesanan gagal di sisi kami, responsnya tetap 200 dengan meta.degraded bernilai true, dan blok customer bisa kosong walau pelanggannya sebenarnya ada. Rincian kegagalan internal tidak pernah dibocorkan ke pemanggil.
#Batas laju
Dihitung per API key, bukan per alamat IP. Menyebar panggilan ke banyak server tidak menambah jatah.
| Jendela | Batas | Melekat pada |
|---|---|---|
| 60 detik | 30 permintaan | API key |
| 1 jam | 600 permintaan | API key |
| 24 jam | 4.000 permintaan | API key |
| 60 detik | 45 permintaan | API key (rem kedua, dihitung di basis data — menangkap ledakan paralel) |
| 60 detik | 120 permintaan | Kombinasi API key + alamat IP (rem sekunder) |
| 60 detik | 1.200 permintaan | Alamat IP saja, diperiksa sebelum key dibaca |
Ketiga jendela per-key berlaku bersamaan; yang pertama tersentuh yang menolak. Rata-rata aman jangka panjang adalah 4.000 per hari, bukan 30 per menit dikalikan 1.440.
Rem 120 per menit melekat pada pasangan key + IP, bukan pada IP saja: dua tenant yang memanggil dari satu IP keluar yang sama punya jatah 120 masing-masing dan tidak bisa saling mengunci. Yang dipakai bersama hanya baris terakhir — 1.200 per menit per IP, yang berjalan sebelum key dibaca dan hanya ada untuk melindungi server dari banjir. Angka itu setara 40 key yang semuanya sedang berada di puncak jatah sahnya.
Saat terlampaui:
HTTP/1.1 429 Too Many Requests
Retry-After: 3600
Content-Type: application/json; charset=utf-8
{"error":"Terlalu banyak permintaan. Coba lagi nanti."}Retry-After selalu ikut dan berisi jumlah detik. Nilainya lebar jendela yang tersentuh (60, 3600, atau 86400), atau sisa detik blokir kalau Anda mencoba lagi saat masih diblokir.
Rincian lengkap ada di Batas Penggunaan.
#Yang kami catat
Setiap pencarian dicatat, termasuk yang tidak menemukan apa pun, dengan nomor tersamar (6281****7890). Nomor lengkap tidak pernah ditulis ke catatan mana pun.
Pola pemanggilan yang menyerupai penyisiran daftar nomor — rasio gagal tinggi ditambah nomor yang nyaris selalu berbeda — ditandai untuk ditinjau manual. Kami tidak mencabut key secara otomatis (mematikan CS tenant di tengah jam ramai lebih merugikan), tetapi pemilik akun bisa mencabutnya sendiri.
#Praktik yang kami sarankan
- Panggil saat ada chat masuk, bukan secara terjadwal. Endpoint ini dirancang untuk satu nomor yang baru saja menghubungi, bukan untuk menyegarkan basis data Anda.
- Jangan memakai
Retry-Aftersebagai satu-satunya rem. Batasi sendiri di sisi Anda, jauh di bawah 30 per menit. - Timeout klien minimal 2 detik. Ada lantai waktu jawab 150 milidetik yang disengaja.
- Simpan
meta.request_iddi log Anda. Itu yang kami minta pertama kali saat Anda melapor. - Periksa
meta.degradedsebelum menyimpulkan "pelanggan tidak terdaftar". - Minta key ber-scope
customers:readsaja. Jangan meminta tenant membuat key ber-scope*.