Webhook Keluar
Referensi webhook keluar AdForm — daftar event, bentuk amplop, contoh payload lengkap, skema tanda tangan HMAC beserta kode verifikasi, aturan kirim ulang, dan kode HTTP yang dianggap berhasil.
AdForm mengirim POST JSON ke alamat milik Anda setiap kali ada peristiwa pesanan di akun penjual. Kiriman ditandatangani, diulang kalau gagal, dan membawa pesan pembuka siap kirim untuk pelanggan.
Alamat tujuan didaftarkan penjual dari dashboard AdForm (Pengaturan → Developer → Webhook). Anda tidak mendaftarkannya lewat API; yang Anda lakukan adalah menyediakan alamat penerima dan memberikannya ke penjual.
#Rangkuman satu layar
| Item | Nilai |
|---|---|
| Protokol | HTTPS saja, port 443 saja |
| Method | POST |
| Content-Type | application/json; charset=utf-8 |
| Tanda tangan | base64(HMAC_SHA256(raw_body, signing_secret)) di header X-AdForm-Hmac-Sha256 |
| Dianggap berhasil | Kode status 2xx apa pun (200–299). Isi body tidak diperiksa. |
| Kirim ulang | Maksimal 6 percobaan: 60 detik, 5 menit, 30 menit, 2 jam, 6 jam |
| Redirect | Tidak diikuti. 301/302 dihitung gagal. |
| Endpoint per akun penjual | Maksimal 3 |
| Kuota keluar per akun penjual | 3.000 kiriman per jam |
| Idempotensi | Wajib di sisi Anda, berbasis unique_id |
Kuota dihitung saat kiriman dimasukkan ke antrean, termasuk kiriman yang dibuat bersama transaksi pesanan atau pembayaran. Setelah batas tercapai, perubahan bisnis tetap tersimpan tetapi tidak membuat baris kiriman baru. Jika pemeriksaan kuota sendiri gagal karena gangguan basis data, antrean tetap mencoba menyimpan kiriman (fail-open); kegagalan penyimpanan tetap mengikuti transaksi pemanggil.
#Daftar event
| Event | Pemicu | Status |
|---|---|---|
order.created | Pesanan baru tersimpan, dari form order publik maupun input manual di dashboard. | Aktif |
order.epayment_created | Gateway berhasil membuat satu instruksi pembayaran elektronik untuk pesanan. | Aktif |
order.updated | Data material pesanan atau pelanggan berubah selain status. | Aktif |
order.deleted | Pesanan berhasil dihapus. Payload memakai snapshot terakhir sebelum penghapusan. | Aktif |
order.status_changed | Nilai status pesanan berubah, baik diubah operator maupun berubah otomatis setelah pembayaran. | Aktif |
order.payment_status_changed | Arti pembayaran berubah, misalnya belum dibayar menjadi dibayar atau dibatalkan. | Aktif |
order.spam_created | Pesanan spam tercatat. | Belum tersedia. AdForm saat ini menolak spam sebelum membuat record, sehingga event ini ditampilkan di katalog tetapi tidak bisa dipilih. |
payment.received | Pembayaran online terkonfirmasi. Dipancarkan sebelum order.status_changed untuk pesanan yang sama. | Aktif |
payment.failed | Status pembayaran elektronik menjadi expired atau failed. | Aktif |
shipment.status_updated | Perpindahan status masuk, bergerak di dalam, atau keluar dari tahap pengiriman. | Aktif |
business.test_event | Tombol Tes Koneksi di dashboard penjual. Tidak pernah dipancarkan otomatis. | Aktif |
Satu perubahan bisa menghasilkan beberapa event dalam hitungan milidetik. Contohnya pembayaran terkonfirmasi dapat menghasilkan payment.received, order.status_changed, dan order.payment_status_changed. Semua membawa snapshot pesanan yang sama. Bedakan lewat field event, jangan lewat isi.
AdForm hanya punya satu kolom status yang mencampur status pembayaran dan tahap pengiriman. Nilainya dikirim di dua key sekaligus (status dan payment_status) dengan isi yang sama persis. Tidak ada field status pengiriman terpisah.
#Amplop
Empat key tingkat atas, sama untuk semua event:
{
"event": "order.created",
"unique_id": "11111111-2222-4333-8444-555555555555",
"timestamp": "2026-07-27T10:15:00+07:00",
"data": { }
}| Field | Tipe | Keterangan |
|---|---|---|
event | string | Nama event dari tabel di atas. |
unique_id | string UUID v4 | Identitas satu kiriman. Tetap sama pada setiap pengiriman ulang. Ini kunci idempotensi yang harus Anda pakai. |
timestamp | string ISO 8601 | Waktu kejadian, bukan waktu kirim. Pengiriman ulang enam jam kemudian tetap membawa timestamp aslinya. |
data | object | Isi event. Bentuknya identik untuk semua event order.* dan payment.received. business.test_event berbeda. |
#Header yang kami kirim
| Header | Isi |
|---|---|
Content-Type | application/json; charset=utf-8. Tetap, tidak bisa diubah penjual. |
X-AdForm-Hmac-Sha256 | Tanda tangan. Lihat bagian Tanda tangan. |
X-AdForm-Event | Sama dengan event di body. |
X-AdForm-Delivery | Sama dengan unique_id di body. |
X-AdForm-Challenge | Terisi hanya pada business.test_event. |
User-Agent | AdForm-Webhook/1.0 |
Accept | application/json |
Body selalu ASCII 7-bit. Karakter non-ASCII — nama dan alamat Indonesia, emoji di dalam pesan — ditulis sebagai \uXXXX. Ini disengaja supaya byte yang ditandatangani identik dengan byte yang terkirim setelah melewati penyimpanan. Secara JSON tetap setara untuk semua parser.
#Contoh payload
#order.created
{
"event": "order.created",
"unique_id": "3f7a1c9e-8b2d-4a6f-9c11-0d5e7a3b9f22",
"timestamp": "2026-07-27T09:41:12+07:00",
"data": {
"id": 48211,
"order_ref": "AF-48211",
"status": "pending",
"payment_status": "pending",
"previous_status": null,
"payment_method": "cod",
"currency": "IDR",
"total": 189000,
"subtotal": 165000,
"shipping_cost": 24000,
"unit_price": 82500,
"total_formatted": "Rp189.000",
"unit_price_formatted": "Rp82.500",
"product_name": "Kemeja Linen Pria Lengan Panjang",
"variant": "Navy / L",
"quantity": 2,
"courier": "JNE",
"courier_service": "REG",
"resi": null,
"notes": "Titip ke satpam kalau saya tidak di rumah",
"cs_name": "Rani",
"cs_phone": "628111222333",
"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\nAlamat Pengiriman:\nBudi Santoso\n6281234567890\nJl. Cihampelas No. 145\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 🙏",
"welcome_template": "default:fu_welcome_cod",
"traffic_source": {
"source": "facebook",
"campaign": "kemeja-linen-juli",
"fbclid": "IwAR0contohPalsu123"
},
"created_at": "2026-07-27T09:41:11+07:00",
"business": {
"client_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"name": "Toko Rapi Sentosa",
"slug": "rapisentosa"
},
"customer": {
"name": "Budi Santoso",
"phone": "6281234567890",
"email": "budi.contoh@example.com",
"address": "Jl. Cihampelas No. 145",
"address_full": "Jl. Cihampelas No. 145, Cipaganti, Coblong, Bandung, Jawa Barat, 40131",
"village": "Cipaganti",
"district": "Coblong",
"city": "Bandung",
"province": "Jawa Barat",
"postal_code": "40131"
},
"items": [
{
"name": "Kemeja Linen Pria Lengan Panjang",
"variant": "Navy / L",
"quantity": 2,
"unit_price": 82500,
"subtotal": 165000,
"bump_name": null,
"product_id": 912
}
]
}
}#payment.received
{
"event": "payment.received",
"unique_id": "7c2e5b41-9d33-4f18-8a70-1b6c4e0a2d59",
"timestamp": "2026-07-27T10:02:44+07:00",
"data": {
"id": 48214,
"order_ref": "AF-48214",
"status": "closing",
"payment_status": "closing",
"previous_status": "pending",
"payment_method": "epayment",
"currency": "IDR",
"total": 275000,
"subtotal": 255000,
"shipping_cost": 20000,
"unit_price": null,
"total_formatted": "Rp275.000",
"unit_price_formatted": null,
"product_name": "Paket Kopi Gayo 500gr (+ BUMP: Tumbler Stainless)",
"variant": "Halus",
"quantity": 1,
"courier": "SiCepat",
"courier_service": "BEST",
"resi": null,
"notes": null,
"cs_name": "Dimas",
"cs_phone": "628119876543",
"welcome_message": null,
"welcome_template": null,
"traffic_source": null,
"created_at": "2026-07-27T09:58:03+07:00",
"business": {
"client_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"name": "Toko Rapi Sentosa",
"slug": "rapisentosa"
},
"customer": {
"name": "Siti Nurhaliza",
"phone": "6285712345678",
"email": null,
"address": "Perum Griya Asri Blok C2 No. 7",
"address_full": "Perum Griya Asri Blok C2 No. 7, Tlogomas, Lowokwaru, Malang, Jawa Timur, 65144",
"village": "Tlogomas",
"district": "Lowokwaru",
"city": "Malang",
"province": "Jawa Timur",
"postal_code": "65144"
},
"items": [
{
"name": "Paket Kopi Gayo 500gr",
"variant": "Halus",
"quantity": 1,
"unit_price": null,
"subtotal": 255000,
"bump_name": "Tumbler Stainless",
"product_id": 455
}
]
}
}Tiga hal yang mudah salah pada contoh ini:
welcome_messagebernilainulldi event ini. Key-nya tetap ada supaya bentukdatasama untuk semua event, tapi kalimatnya hanya diisi padaorder.created. Sebelumnya ia dirender ulang di sini juga — akibatnya pesanan transfer yang baru ditandai lunas membawa kalimat "Silakan lakukan pembayaran ke rekening berikut" pada event yang artinya justru "sudah lunas". Kalau konektor Anda menampilkan kalimat itu di panel untuk event ini, hentikan: nilainya sekarang selalunull, dan itu bukan tanda ada yang rusak.- Pesanan e-payment memakai cabang templatenya sendiri.
payment_methodbernilaiepayment, dan padaorder.createdcabang yang terpilihfu_welcome_epayment— bukanfu_welcome_transfer. Pesannya tidak memuat nomor rekening dan tidak menyuruh pembeli transfer manual. Sebelumnya pesanan seperti ini jatuh ke cabang Transfer dan pembeli yang sudah lunas tetap disuruh transfer; itu sudah diperbaiki. Penjual menyunting ketiga cabang itu sendiri di dashboard, dan layar penjual memakai perender yang sama dengan yang mengisi field ini. - Order bump membuat
unit_pricebernilainull. AdForm tidak menyimpan harga satuan terpisah; menebaknya saat ada bump menghasilkan angka salah di pesan ke pelanggan.product_nametingkat atas memuat penanda(+ BUMP: …)apa adanya, sedangkanitems[0].namesudah dibersihkan dan nama bump-nya pindah keitems[0].bump_name. Untuk kalimat pesan, pakaiitems[0].name.
#order.status_changed
Bentuk data-nya identik dengan dua contoh di atas. Yang membedakan hanya nilainya:
{
"event": "order.status_changed",
"unique_id": "b4d90a17-6c2f-4e83-91aa-3f5d8c7e1042",
"timestamp": "2026-07-27T14:20:09+07:00",
"data": {
"id": 48214,
"order_ref": "AF-48214",
"status": "shipped",
"payment_status": "shipped",
"previous_status": "closing",
"resi": "005412998877",
"courier": "SiCepat",
"courier_service": "BEST",
"welcome_message": null,
"welcome_template": null,
"customer": {
"name": "Siti Nurhaliza",
"phone": "6285712345678"
}
}
}Contoh di atas dipendekkan supaya perbedaannya terlihat. Kiriman sungguhan membawa semua field dari tabel di bawah, sama seperti dua contoh sebelumnya.
#Metadata khusus event lain
Semua event aktif selain business.test_event tetap membawa snapshot lengkap dari tabel field di bawah. Beberapa event menambahkan metadata berikut:
| Event | Metadata tambahan | Catatan |
|---|---|---|
order.status_changed | changed_by, integration, external_reference | Hanya muncul kalau perubahannya berasal dari integrasi, tidak pernah pada perubahan dari dashboard. Lihat penjelasan di bawah tabel. |
order.updated | changed_fields: string[] | Path publik yang berubah, misalnya customer.name, customer.phone, customer.address, atau payment_method. Field internal tidak dimasukkan. |
order.deleted | is_deleted: true, deleted_at | Snapshot diambil sebelum baris pesanan dihapus. |
order.epayment_created | epayment | Hanya provider, channel, type, amount, fee, total, expires_at, dan attempt_id. Nomor VA, QR mentah, dan instruksi bayar tidak pernah dimasukkan. |
#Asal-usul perubahan pada order.status_changed
Sejak API status pesanan tersedia, status bisa berpindah karena sistem lain memanggil /api/order_status.php, bukan hanya karena penjual menekan tombol. Tiga field berikut menandai asal-usulnya:
| Field | Nilai | Arti |
|---|---|---|
changed_by | integration | Perubahan berasal dari pemanggilan API. Daftar nilainya dikunci di sisi AdForm; nilai di luar daftar dibuang, bukan diteruskan. |
integration | teks, maks. 64 karakter | Nama key API yang dipakai, ditulis sendiri oleh penjual. Nama key wajib berbeda antar-integrasi aktif di satu toko, jadi nilai ini cukup untuk membedakan pemanggil. |
external_reference | teks, maks. 120 karakter | Nomor rujukan milik sistem pemanggil, kalau ia mengirimkannya. |
Ketiganya tidak muncul sama sekali pada perubahan dari dashboard. Bentuk payload yang sudah Anda tangani karena itu tidak berubah, dan Anda tidak perlu menyesuaikan apa pun. Gunakan keberadaan changed_by untuk membedakan perubahan yang berasal dari sistem Anda sendiri — tanpa itu, dua sistem bisa saling memantulkan status.
order.payment_status_changed, payment.failed, dan shipment.status_updated tidak menambah field khusus. Arti transisinya dibaca dari previous_status dan status.
#business.test_event
data-nya berbeda dari event pesanan. Hanya tiga field.
{
"event": "business.test_event",
"unique_id": "11111111-2222-4333-8444-555555555555",
"timestamp": "2026-07-27T10:15:00+07:00",
"data": {
"message": "Tes koneksi dari AdForm. Balas kode di bawah untuk mengaktifkan sambungan.",
"challenge": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"business": {
"client_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"name": "Toko Rapi Sentosa",
"slug": "rapisentosa"
}
}
}#Tabel field data untuk event pesanan
"Wajib" berarti key selalu ada. null tetap dihitung "ada". Key yang tidak kami punya sumbernya tidak dikirim sama sekali, bukan dikirim null selamanya.
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
id | integer | wajib | ID pesanan di AdForm. Unik per akun penjual, bukan unik lintas akun. |
order_ref | string | wajib | "AF-" + id. Kosmetik. AdForm tidak punya nomor invoice sendiri. |
status | string | wajib | Nilai mentah status pesanan. Contoh: pending, closing, paid, shipped, completed, cancelled. Daftarnya bisa bertambah — perlakukan sebagai string bebas, jangan enum keras. |
payment_status | string | wajib | Sama persis dengan status. Duplikat sengaja. |
previous_status | string | null | wajib | Status sebelum perubahan. null pada order.created. |
payment_method | string | null | wajib | Contoh: cod, bank_transfer, epayment. |
currency | string | wajib | Selalu "IDR". |
total | integer | wajib | Total tagihan dalam rupiah penuh, bukan sen. Sudah termasuk ongkir. |
subtotal | integer | wajib | total - shipping_cost. |
shipping_cost | integer | wajib | Ongkir. 0 kalau tidak ada. |
unit_price | integer | null | wajib | Harga satuan. null kalau pesanan memuat order bump atau kalau pembagiannya tidak bulat. |
total_formatted | string | null | wajib | Bentuk siap tempel, mis. "Rp189.000". |
unit_price_formatted | string | null | wajib | Bentuk siap tempel. null mengikuti unit_price. |
product_name | string | wajib | Nama produk apa adanya, termasuk penanda bump. |
variant | string | null | wajib | Varian produk, mis. "Navy / L". |
quantity | integer | wajib | Minimal 1. |
courier | string | null | wajib | Nama kurir. |
courier_service | string | null | wajib | Layanan kurir, mis. "REG". |
resi | string | null | wajib | Nomor resi. null saat pesanan belum dikirim. |
notes | string | null | wajib | Catatan dari pelanggan. |
cs_name | string | null | wajib | Nama CS yang ditugaskan di AdForm. |
cs_phone | string | null | wajib | Nomor WhatsApp CS, format 62. |
welcome_message | string | null | wajib | Pesan pembuka siap kirim. Terisi hanya pada order.created; null di event lain. Lihat bagian berikutnya. |
welcome_template | string | null | wajib | Penanda asal template, bentuknya "<lapisan>:<kunci>". Untuk penelusuran kalau isinya salah. |
traffic_source | object | null | wajib | Data sumber iklan apa adanya. Tidak berskema tetap — jangan asumsikan key tertentu ada. |
created_at | string ISO 8601 | null | wajib | Waktu pesanan dibuat. |
business.client_id | string UUID | wajib | Identitas akun penjual. Inilah yang dipakai mencocokkan kiriman ke koneksi penjual di sistem Anda. Tetap sepanjang umur akun. Jangan memakai name atau slug sebagai kunci. |
business.name | string | wajib | Nama toko. Boleh string kosong kalau penjual belum mengisinya. |
business.slug | string | wajib | Slug akun penjual. |
customer.name | string | null | wajib | Nama pelanggan. |
customer.phone | string | null | wajib | Selalu format 62, tanpa + dan tanpa spasi, mis. 6281234567890. null kalau nomornya tidak valid. |
customer.email | string | null | wajib | |
customer.address | string | null | wajib | Baris alamat saja. |
customer.address_full | string | null | wajib | Alamat satu baris siap tempel: alamat, kelurahan, kecamatan, kota, provinsi, kode pos. |
customer.village | string | null | wajib | Kelurahan. |
customer.district | string | null | wajib | Kecamatan. |
customer.city | string | null | wajib | Kota atau kabupaten. |
customer.province | string | null | wajib | Provinsi. |
customer.postal_code | string | null | wajib | Kode pos. |
items | array | wajib | Selalu tepat satu elemen. Model data AdForm adalah satu pesanan satu produk. |
items[].name | string | wajib | Nama produk tanpa penanda bump. |
items[].variant | string | null | wajib | |
items[].quantity | integer | wajib | |
items[].unit_price | integer | null | wajib | null kalau ada bump. |
items[].subtotal | integer | wajib | |
items[].bump_name | string | null | wajib | Nama produk tambahan, kalau ada. |
items[].product_id | integer | null | wajib |
#welcome_message — pesan pembuka siap kirim
Ini bukan data, melainkan kalimat jadi. Dikarang penjual di dashboard AdForm (Pengaturan → Follow-Up), bukan ditulis di sistem Anda. Tanpa field ini, konektor tidak punya kalimat apa pun untuk dikirim ke pelanggan.
Aturannya:
- Kirim apa adanya pada
order.created. Jangan dipotong, dirapikan, di-parse, atau diganti kalimat karangan sendiri. - Event lain tidak membawanya. Pada semua event selain
order.creatednilainyanull; key-nya ada cuma supaya bentukdataseragam. nullberarti tidak ada pesan yang bisa dikirim. Jangan mengarang penggantinya, dan jangan menganggapnya galat.- Nomor rekening penjual yang belum diisi tidak lagi membungkam pesannya. Pesanan transfer di toko yang belum mengisi rekening tetap dapat pesan pembuka; di tempat nomor rekening ada kalimat "(Nomor rekeningnya kami kirimkan menyusul lewat chat ini ya Kak.)". Sebelumnya keadaan itu membuat
welcome_messagebernilainull, dan karena metode bayar bawaan di AdForm adalah transfer, toko COD dan toko produk digital pun kehilangan pesan pembukanya untuk setiap pesanan.
Empat jebakan format:
\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. Perhatikan: fieldtotal_formatteddanunit_price_formattedmemakai gaya lain (Rp275.000, tanpa spasi). Jangan mencampur keduanya dalam satu percakapan, dan pakaitotal/shipping_costkalau perlu berhitung. - Jangan mem-parse nomor telepon dari dalam badan pesan. Pakai
data.customer.phone. - Panjang dan susunannya berbeda-beda antar pesanan. Baris yang nilainya kosong dibuang sebelum dikirim: pembeli produk digital tidak punya alamat, jadi blok
Alamat Pengiriman:(judul, nama, nomor, alamat) tidak ikut — tapi dua kalimat bawaan sesudahnya, "Mohon pastikan alamat di atas sudah lengkap dan benar" dst, tetap ikut karena ada di paragraf terpisah. Ongkir0membuat barisOngkir:tidak ikut. Jangan menulis parser yang mengandalkan jumlah baris, urutan blok, atau kehadiran label tertentu.
Kalimat yang sama juga bisa ditarik lewat Pencarian Pelanggan saat pelanggan mengirim chat duluan. Beda utamanya: versi yang ditarik kehilangan seluruh blok alamat — baris alamat, judul bloknya, nama dan nomor di bawahnya, serta kalimat yang menyuruh pelanggan memeriksa alamat. Versi di webhook ini memuat semuanya. Kalau sistem Anda memakai dua jalur itu sekaligus, kirim salah satu saja — bukan dua-duanya.
Selisih lain yang dulu ada di jalur tarik sudah tidak ada: penanda yang tidak kami kenal (salah ketik penjual) sekarang barisnya dibuang di sana juga. Yang tersisa sebagai perbedaan yang disengaja cuma blok alamat. Untuk keadaan "rekening belum diisi", jalur tarik menambahkan welcome.warning di jawabannya; jalur webhook ini tidak punya penandanya.
#Tanda tangan
signature = base64( HMAC_SHA256( raw_request_body, signing_secret ) )Aturan yang paling sering salah:
- Yang ditandatangani adalah raw body apa adanya. Bukan body hasil parse lalu diserialisasi ulang. Satu byte berbeda menghasilkan tanda tangan berbeda.
- Digest di-base64, bukan heksadesimal.
- Tidak ada prefiks
sha256=. - Timestamp, method, dan path tidak ikut ditandatangani. Hanya body.
signing_secretdibuat otomatis di dashboard AdForm dan nilai lengkapnya disembunyikan secara default. Owner/admin berizin dapat menampilkan atau menyalinnya kembali melalui tindakan eksplisit; nilai lengkap tidak disertakan dalam response daftar, simpan, rotasi, atau log. Jika secret dirotasi, kiriman yang masih berada dalam antrean akan ditandatangani menggunakan secret baru.
Karena timestamp tidak ikut ditandatangani, menolak kiriman karena timestamp-nya tua bukan perlindungan anti-ulang. Perlindungan yang benar adalah idempotensi berbasis unique_id.
#Contoh yang bisa diverifikasi langsung
Rahasia contoh: whsec_CONTOH_JANGAN_DIPAKAI_0000
Raw body, satu baris, tanpa baris baru di akhir:
{"event":"business.test_event","unique_id":"11111111-2222-4333-8444-555555555555","timestamp":"2026-07-27T10:15:00+07:00","data":{"message":"Tes koneksi dari AdForm","challenge":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"}}Tanda tangan yang harus dihasilkan:
87495DEQh6ElsMfLUX3l3lj3WfVRzyJdWmY1wwxIldQ=Kalau kode Anda menghasilkan nilai lain untuk masukan itu, masalahnya di sisi Anda dan hampir selalu karena body-nya diserialisasi ulang.
#Verifikasi — PHP polos
<?php
// Ambil body MENTAH. Jangan pakai $_POST, jangan json_encode ulang hasil json_decode.
$raw = file_get_contents('php://input');
$secret = getenv('ADFORM_WEBHOOK_SECRET');
$sent = $_SERVER['HTTP_X_ADFORM_HMAC_SHA256'] ?? '';
$expected = base64_encode(hash_hmac('sha256', $raw, $secret, true));
// hash_equals = perbandingan waktu-tetap. Jangan pakai === atau ==.
if (!hash_equals($expected, $sent)) {
http_response_code(401);
exit(json_encode(['error' => 'invalid signature']));
}
$payload = json_decode($raw, true);
// Idempotensi: unique_id sama = kiriman yang sama, walau datang berkali-kali.
if (sudahDiproses($payload['unique_id'])) {
http_response_code(200);
exit('{"ok":true,"duplicate":true}');
}
// business.test_event: pantulkan challenge, kalau tidak sambungan tidak akan aktif.
if ($payload['event'] === 'business.test_event') {
http_response_code(200);
exit(json_encode(['ok' => true, 'challenge' => $payload['data']['challenge']]));
}
prosesEvent($payload);
http_response_code(200);
echo '{"ok":true}';#Verifikasi — Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
// PENTING: ambil body sebagai Buffer mentah. express.json() yang default
// membuang byte aslinya, dan tanda tangan tidak akan pernah cocok.
app.use(express.raw({ type: '*/*', limit: '1mb' }));
app.post('/adform/webhook', (req, res) => {
const raw = req.body; // Buffer
const secret = process.env.ADFORM_WEBHOOK_SECRET;
const sent =
req.get('X-AdForm-Hmac-Sha256') || '';
const expected = crypto
.createHmac('sha256', secret)
.update(raw)
.digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(sent);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'invalid signature' });
}
const payload = JSON.parse(raw.toString('utf8'));
if (alreadyProcessed(payload.unique_id)) {
return res.status(200).json({ ok: true, duplicate: true });
}
if (payload.event === 'business.test_event') {
// Pantulkan nilai challenge. Tanpa ini endpoint tidak pernah lolos verifikasi.
return res.status(200).json({ ok: true, challenge: payload.data.challenge });
}
handleEvent(payload);
res.status(200).json({ ok: true });
});#Verifikasi — Laravel
Jebakan nomor satu di Laravel. Laravel mem-parse body JSON jadi array PHP. Menghitung HMAC dari hasil parse itu tidak akan pernah cocok —
json_encode($request->all()),json_encode($request->json()->all()), danjson_encode($request->input())semuanya menghasilkan byte berbeda dari byte yang kami tanda tangani: urutan kunci bisa berubah, garis miring di-escape berbeda, spasi hilang. Satu byte berbeda =401selamanya, tanpa petunjuk apa pun. Yang benar hanya$request->getContent().
Middleware — salin ke app/Http/Middleware/VerifyAdFormSignature.php:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class VerifyAdFormSignature
{
public function handle(Request $request, Closure $next): Response
{
$secret = (string) config('services.adform.webhook_secret', '');
// Rahasia belum dipasang = tolak. Jangan pernah "lolos kalau kosong":
// satu salah ketik di .env akan membuka endpoint ini ke siapa pun.
if ($secret === '') {
Log::error('[adform-webhook] ADFORM_WEBHOOK_SECRET kosong — kiriman ditolak.');
return response()->json(['error' => 'webhook not configured'], 500);
}
// BYTE MENTAH. Dibaca sekali. Jangan json_encode ulang apa pun.
// getContent() mengembalikan byte asli dari php://input dan Laravel
// meng-cache hasilnya, jadi controller tidak kehilangan body.
$raw = $request->getContent();
$sent = (string) (
$request->header('X-AdForm-Hmac-Sha256') ?? ''
);
if ($sent === '') {
return response()->json(['error' => 'missing signature'], 401);
}
$expected = base64_encode(hash_hmac('sha256', $raw, $secret, true));
// hash_equals() = perbandingan waktu-tetap. JANGAN pakai === atau ==:
// perbandingan string biasa berhenti di byte pertama yang berbeda, dan
// selisih waktunya bisa dipakai menebak tanda tangan byte demi byte.
if (! hash_equals($expected, $sent)) {
Log::warning('[adform-webhook] tanda tangan tidak cocok', [
'delivery' => $request->header('X-AdForm-Delivery'),
'event' => $request->header('X-AdForm-Event'),
]);
return response()->json(['error' => 'invalid signature'], 401);
}
// Decode byte YANG SAMA dengan yang baru saja diverifikasi.
$payload = json_decode($raw, true);
if (! is_array($payload)) {
return response()->json(['error' => 'invalid json'], 400);
}
$request->attributes->set('adform_payload', $payload);
return $next($request);
}
}Controller — salin ke app/Http/Controllers/AdFormWebhookController.php:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AdFormWebhookController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$payload = (array) $request->attributes->get('adform_payload', []);
$event = (string) ($payload['event'] ?? '');
$uniqueId = (string) ($payload['unique_id'] ?? '');
$data = (array) ($payload['data'] ?? []);
// Tes koneksi — WAJIB dijawab dengan memantulkan challenge, dan dijawab
// SEBELUM pemeriksaan idempotensi: penjual boleh menekan tombolnya
// berkali-kali, dan setiap kali harus dijawab.
if ($event === 'business.test_event') {
return response()->json([
'ok' => true,
'challenge' => (string) ($data['challenge'] ?? ''),
]);
}
// Idempotensi. unique_id TETAP SAMA pada setiap pengiriman ulang.
// Cache::add() bersifat atomik (set-if-not-exists), jadi dua proses yang
// menerima kiriman ulang bersamaan tidak akan dua-duanya lolos.
$lockKey = 'adform:wh:'.$uniqueId;
if ($uniqueId !== '' && ! Cache::add($lockKey, 1, now()->addDays(7))) {
return response()->json(['ok' => true, 'duplicate' => true]);
}
$welcome = $data['welcome_message'] ?? null;
try {
switch ($event) {
case 'order.created':
// HANYA di sini pesan pembuka dikirim ke pelanggan.
if (is_string($welcome) && trim($welcome) !== '') {
$this->kirimWhatsApp(
(string) ($data['customer']['phone'] ?? ''),
$welcome
);
}
$this->simpanPesanan($data);
break;
case 'payment.received':
case 'order.status_changed':
// welcome_message bernilai null di event ini — tidak ada
// yang bisa dikirim ulang, dan itu memang disengaja.
$this->perbaruiPesanan($data);
break;
default:
Log::info('[adform-webhook] event tidak ditangani', ['event' => $event]);
}
} catch (\Throwable $e) {
// Lepaskan kunci idempotensi supaya pengiriman ulang dari AdForm bisa
// memproses ulang kiriman yang gagal di tengah jalan.
if ($uniqueId !== '') {
Cache::forget($lockKey);
}
Log::error('[adform-webhook] gagal memproses', [
'event' => $event,
'unique_id' => $uniqueId,
'error' => $e->getMessage(),
]);
// 5xx = AdForm akan mengulang sesuai jadwal di bawah.
return response()->json(['error' => 'processing failed'], 500);
}
return response()->json(['ok' => true]);
}
/** Isi sendiri. $phone sudah format 62, atau kosong kalau nomornya tidak valid. */
private function kirimWhatsApp(string $phone, string $message): void {}
/** Isi sendiri. Kunci penjual = $data['business']['client_id'], bukan slug/nama. */
private function simpanPesanan(array $data): void {}
/** Isi sendiri. Urutan kedatangan TIDAK dijamin — susun dari previous_status + timestamp. */
private function perbaruiPesanan(array $data): void {}
}Pasang rutenya di routes/api.php:
Route::post('/adform/webhook', \App\Http\Controllers\AdFormWebhookController::class)
->middleware(\App\Http\Middleware\VerifyAdFormSignature::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);#Kode HTTP yang dianggap berhasil
2xx apa pun (200–299). Isi body respons tidak diperiksa, kecuali pada business.test_event.
Semua kode lain, semua timeout, dan semua kegagalan TLS dihitung gagal dan memicu pengiriman ulang. Termasuk 3xx: redirect tidak diikuti.
Balaslah cepat. Terima, simpan ke antrean Anda, balas 2xx, kerjakan belakangan. Jangan memproses berat di dalam request.
#Kirim ulang
Jadwal setelah kegagalan ke-1 sampai ke-5:
| Percobaan gagal ke- | Jeda sebelum percobaan berikutnya |
|---|---|
| 1 | 60 detik |
| 2 | 5 menit |
| 3 | 30 menit |
| 4 | 2 jam |
| 5 | 6 jam |
Maksimum 6 percobaan. Setelah itu kiriman ditandai mati dan tidak dicoba lagi.
Pengecualian yang menghentikan lebih awal:
| Kondisi | Perilaku |
|---|---|
Respons 410 Gone | Berhenti seketika, tidak ada percobaan lagi. |
Respons 404 pada percobaan ke-3 | Berhenti. Dua 404 pertama masih diulang — alat otomasi sering membalas 404 saat alurnya sedang diedit, lalu 200 semenit kemudian. |
| Alamat tujuan me-resolve ke jaringan internal | Berhenti seketika, permanen. |
Kode gagal lain (4xx selain di atas, 5xx, timeout) | Diulang sampai batas 6 percobaan. |
Endpoint tidak pernah dimatikan otomatis oleh sistem. Deretan kegagalan terlihat di riwayat pengiriman di dashboard penjual.
#Batas teknis pengiriman
| Parameter | Nilai |
|---|---|
| Protokol | HTTPS saja, port 443 saja |
| Sertifikat | Diverifikasi. Sertifikat kedaluwarsa atau tidak tepercaya = gagal. |
| Redirect | Tidak diikuti |
| Timeout koneksi / total | 4 detik / 8 detik dari pekerja antrian. Untuk kiriman kilat yang menyusul submit pesanan: 2 detik / 2 detik. |
| Kecepatan minimum | Koneksi diputus kalau di bawah 64 byte per detik selama 4 detik |
| Ukuran respons yang dibaca | Maksimal 256 KB, sisanya diputus |
| Endpoint per akun penjual | Maksimal 3 |
| Kuota keluar per akun penjual | 3.000 kiriman per jam |
#Idempotensi dan urutan
Dedup wajib memakai unique_id. Pengiriman ulang membawa unique_id yang sama.
Di sisi kami, order.created, payment.received, payment.failed, dan order.deleted hanya diantrikan sekali seumur hidup per pesanan. order.epayment_created unik per identitas transaksi gateway (project, ID pesanan gateway, nominal, dan metode pembayaran), bukan per percobaan HTTP. Identitas ini juga dipakai untuk memulihkan event bila gateway sudah berhasil tetapi proses aplikasi terputus sebelum event sempat diantrikan. order.status_changed, order.payment_status_changed, shipment.status_updated, dan order.updated tidak dijamin unik — satu pesanan boleh berubah berkali-kali, dan setiap perubahan menghasilkan kiriman baru dengan unique_id baru.
Urutan tidak dijamin. Kiriman yang sempat gagal lalu berhasil pada percobaan ketiga akan tiba setelah kiriman yang lebih baru. Susun urutan dari timestamp amplop dan previous_status, jangan dari urutan kedatangan.
#Retensi riwayat
Kiriman yang masih queued atau sending mempertahankan payload lengkap agar byte yang dicoba ulang tetap identik. Setelah kiriman mencapai status terminal (sent, failed, dead, atau canceled), payload mentah yang dapat memuat nama, nomor telepon, dan alamat dihapus setelah 24 jam. Metadata riwayat terminal dihapus setelah 7 hari.
Simpan salinan event di sistem Anda sendiri bila memerlukan arsip lebih lama. Terapkan masa simpan dan pembatasan akses sesuai kebutuhan bisnis Anda.
#Verifikasi kepemilikan endpoint
Endpoint yang baru disimpan penjual tidak menerima trafik apa pun sampai lolos Tes Koneksi. Ini pagar anti-penyalahgunaan: tanpa itu, siapa saja bisa mengarahkan server AdForm mem-POST ke host pihak ketiga.
Alurnya:
- Penjual menekan Tes Koneksi di dashboard AdForm.
- AdForm mengirim
business.test_eventyang membawa nilai acak 32 heksadesimal didata.challengedan di headerX-AdForm-Challenge. - Penerima harus membalas
2xxdan memuat nilai challenge itu di body respons atau header respons apa pun. Pencocokan tidak peduli huruf besar-kecil dan tidak peduli posisinya di dalam teks. - Kalau nilainya tidak dipantulkan, endpoint tetap terkunci walau balasannya
200.
Cara termudah memenuhinya: balas {"ok":true,"challenge":"<nilai>"}.
Balasan 2xx saja tidak cukup, dan itu disengaja — situs pihak ketiga mana pun membalas 2xx untuk POST. Yang membuktikan kepemilikan adalah nonce acak kami yang dipantulkan kembali.
Kalau penjual mengganti URL endpoint, verifikasinya hangus. Antrian yang sudah menumpuk tidak akan dikirim ke alamat baru sampai alamat itu lolos Tes Koneksi lagi.
#Jeda kirim order.created
Penjual boleh menyetel jeda 0 sampai 21.600 detik (6 jam) untuk order.created saja. Semua event lain tidak pernah dijeda, berapa pun setelan penjual.
Alasannya bukan teknis, melainkan anti-blokir WhatsApp. order.created memicu sistem Anda menyapa duluan nomor yang belum pernah menghubungi penjual. Itu pola yang paling sering memicu pemblokiran nomor pengirim. Jeda memberi pelanggan kesempatan mengirim chat lebih dulu.
Kalau pelanggan memang mengirim chat lebih dulu dan sistem Anda mengambil pesan pembukanya lewat Pencarian Pelanggan, kiriman order.created yang masih tertunda untuk pesanan itu dibatalkan, supaya pelanggan tidak menerima kalimat yang sama dua kali. Syarat keduanya: penjual sudah menyatakan alamat tujuan itu sebagai pengirim pesan pembuka.
Kalau salah satu syarat tidak terpenuhi — Anda belum mengambil kalimatnya, atau penjual belum menjawab saklarnya — tidak ada yang dibatalkan. Jadwalnya cuma digeser sekali sebanyak 120 detik, lalu kirimannya tetap berangkat. Ini disengaja: order.created tidak boleh hilang tanpa ada penggantinya.
Ada satu jendela selebar satu perjalanan HTTP yang tidak bisa ditutup: kalau pembatalan tiba setelah kiriman sudah terbang, permintaan itu tidak bisa ditarik kembali. Karena itu idempotensi di sisi Anda tetap wajib, dan itu juga alasan kami memilih arah gagal "mungkin dua kali" daripada "mungkin nol kali".
#Yang perlu Anda siapkan
- Endpoint HTTPS publik di port 443, dengan sertifikat yang valid, yang menerima
POSTJSON. - Verifikasi tanda tangan dari raw body. Tolak
401kalau tidak cocok. - Pantulan
data.challengeuntukbusiness.test_event. - Idempotensi berbasis
unique_id. - Balasan
2xxcepat, pemrosesan berat di belakang. - Penanganan
welcome_message: kirim padaorder.created. Di event lain nilainyanull— jangan perlakukan itu sebagai galat.
Kalau ada yang tidak jelas atau perilakunya berbeda dari halaman ini, hubungi kami lewat Pusat Bantuan dan sertakan nilai X-AdForm-Delivery dari kiriman yang bermasalah.