Lompat ke konten utama
Dokumentasi AdForm
llms.txt Pusat Bantuan

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

ItemNilai
ProtokolHTTPS saja, port 443 saja
MethodPOST
Content-Typeapplication/json; charset=utf-8
Tanda tanganbase64(HMAC_SHA256(raw_body, signing_secret)) di header X-AdForm-Hmac-Sha256
Dianggap berhasilKode status 2xx apa pun (200–299). Isi body tidak diperiksa.
Kirim ulangMaksimal 6 percobaan: 60 detik, 5 menit, 30 menit, 2 jam, 6 jam
RedirectTidak diikuti. 301/302 dihitung gagal.
Endpoint per akun penjualMaksimal 3
Kuota keluar per akun penjual3.000 kiriman per jam
IdempotensiWajib 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

EventPemicuStatus
order.createdPesanan baru tersimpan, dari form order publik maupun input manual di dashboard.Aktif
order.epayment_createdGateway berhasil membuat satu instruksi pembayaran elektronik untuk pesanan.Aktif
order.updatedData material pesanan atau pelanggan berubah selain status.Aktif
order.deletedPesanan berhasil dihapus. Payload memakai snapshot terakhir sebelum penghapusan.Aktif
order.status_changedNilai status pesanan berubah, baik diubah operator maupun berubah otomatis setelah pembayaran.Aktif
order.payment_status_changedArti pembayaran berubah, misalnya belum dibayar menjadi dibayar atau dibatalkan.Aktif
order.spam_createdPesanan spam tercatat.Belum tersedia. AdForm saat ini menolak spam sebelum membuat record, sehingga event ini ditampilkan di katalog tetapi tidak bisa dipilih.
payment.receivedPembayaran online terkonfirmasi. Dipancarkan sebelum order.status_changed untuk pesanan yang sama.Aktif
payment.failedStatus pembayaran elektronik menjadi expired atau failed.Aktif
shipment.status_updatedPerpindahan status masuk, bergerak di dalam, atau keluar dari tahap pengiriman.Aktif
business.test_eventTombol 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:

JSON
{
  "event": "order.created",
  "unique_id": "11111111-2222-4333-8444-555555555555",
  "timestamp": "2026-07-27T10:15:00+07:00",
  "data": { }
}
FieldTipeKeterangan
eventstringNama event dari tabel di atas.
unique_idstring UUID v4Identitas satu kiriman. Tetap sama pada setiap pengiriman ulang. Ini kunci idempotensi yang harus Anda pakai.
timestampstring ISO 8601Waktu kejadian, bukan waktu kirim. Pengiriman ulang enam jam kemudian tetap membawa timestamp aslinya.
dataobjectIsi event. Bentuknya identik untuk semua event order.* dan payment.received. business.test_event berbeda.

#Header yang kami kirim

HeaderIsi
Content-Typeapplication/json; charset=utf-8. Tetap, tidak bisa diubah penjual.
X-AdForm-Hmac-Sha256Tanda tangan. Lihat bagian Tanda tangan.
X-AdForm-EventSama dengan event di body.
X-AdForm-DeliverySama dengan unique_id di body.
X-AdForm-ChallengeTerisi hanya pada business.test_event.
User-AgentAdForm-Webhook/1.0
Acceptapplication/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

JSON
{
  "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

JSON
{
  "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:

  1. welcome_message bernilai null di event ini. Key-nya tetap ada supaya bentuk data sama untuk semua event, tapi kalimatnya hanya diisi pada order.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 selalu null, dan itu bukan tanda ada yang rusak.
  2. Pesanan e-payment memakai cabang templatenya sendiri. payment_method bernilai epayment, dan pada order.created cabang yang terpilih fu_welcome_epayment — bukan fu_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.
  3. Order bump membuat unit_price bernilai null. AdForm tidak menyimpan harga satuan terpisah; menebaknya saat ada bump menghasilkan angka salah di pesan ke pelanggan. product_name tingkat atas memuat penanda (+ BUMP: …) apa adanya, sedangkan items[0].name sudah dibersihkan dan nama bump-nya pindah ke items[0].bump_name. Untuk kalimat pesan, pakai items[0].name.

#order.status_changed

Bentuk data-nya identik dengan dua contoh di atas. Yang membedakan hanya nilainya:

JSON
{
  "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:

EventMetadata tambahanCatatan
order.status_changedchanged_by, integration, external_referenceHanya muncul kalau perubahannya berasal dari integrasi, tidak pernah pada perubahan dari dashboard. Lihat penjelasan di bawah tabel.
order.updatedchanged_fields: string[]Path publik yang berubah, misalnya customer.name, customer.phone, customer.address, atau payment_method. Field internal tidak dimasukkan.
order.deletedis_deleted: true, deleted_atSnapshot diambil sebelum baris pesanan dihapus.
order.epayment_createdepaymentHanya 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:

FieldNilaiArti
changed_byintegrationPerubahan berasal dari pemanggilan API. Daftar nilainya dikunci di sisi AdForm; nilai di luar daftar dibuang, bukan diteruskan.
integrationteks, maks. 64 karakterNama 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_referenceteks, maks. 120 karakterNomor 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.

JSON
{
  "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.

FieldTipeWajibKeterangan
idintegerwajibID pesanan di AdForm. Unik per akun penjual, bukan unik lintas akun.
order_refstringwajib"AF-" + id. Kosmetik. AdForm tidak punya nomor invoice sendiri.
statusstringwajibNilai mentah status pesanan. Contoh: pending, closing, paid, shipped, completed, cancelled. Daftarnya bisa bertambah — perlakukan sebagai string bebas, jangan enum keras.
payment_statusstringwajibSama persis dengan status. Duplikat sengaja.
previous_statusstring | nullwajibStatus sebelum perubahan. null pada order.created.
payment_methodstring | nullwajibContoh: cod, bank_transfer, epayment.
currencystringwajibSelalu "IDR".
totalintegerwajibTotal tagihan dalam rupiah penuh, bukan sen. Sudah termasuk ongkir.
subtotalintegerwajibtotal - shipping_cost.
shipping_costintegerwajibOngkir. 0 kalau tidak ada.
unit_priceinteger | nullwajibHarga satuan. null kalau pesanan memuat order bump atau kalau pembagiannya tidak bulat.
total_formattedstring | nullwajibBentuk siap tempel, mis. "Rp189.000".
unit_price_formattedstring | nullwajibBentuk siap tempel. null mengikuti unit_price.
product_namestringwajibNama produk apa adanya, termasuk penanda bump.
variantstring | nullwajibVarian produk, mis. "Navy / L".
quantityintegerwajibMinimal 1.
courierstring | nullwajibNama kurir.
courier_servicestring | nullwajibLayanan kurir, mis. "REG".
resistring | nullwajibNomor resi. null saat pesanan belum dikirim.
notesstring | nullwajibCatatan dari pelanggan.
cs_namestring | nullwajibNama CS yang ditugaskan di AdForm.
cs_phonestring | nullwajibNomor WhatsApp CS, format 62.
welcome_messagestring | nullwajibPesan pembuka siap kirim. Terisi hanya pada order.created; null di event lain. Lihat bagian berikutnya.
welcome_templatestring | nullwajibPenanda asal template, bentuknya "<lapisan>:<kunci>". Untuk penelusuran kalau isinya salah.
traffic_sourceobject | nullwajibData sumber iklan apa adanya. Tidak berskema tetap — jangan asumsikan key tertentu ada.
created_atstring ISO 8601 | nullwajibWaktu pesanan dibuat.
business.client_idstring UUIDwajibIdentitas 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.namestringwajibNama toko. Boleh string kosong kalau penjual belum mengisinya.
business.slugstringwajibSlug akun penjual.
customer.namestring | nullwajibNama pelanggan.
customer.phonestring | nullwajibSelalu format 62, tanpa + dan tanpa spasi, mis. 6281234567890. null kalau nomornya tidak valid.
customer.emailstring | nullwajib
customer.addressstring | nullwajibBaris alamat saja.
customer.address_fullstring | nullwajibAlamat satu baris siap tempel: alamat, kelurahan, kecamatan, kota, provinsi, kode pos.
customer.villagestring | nullwajibKelurahan.
customer.districtstring | nullwajibKecamatan.
customer.citystring | nullwajibKota atau kabupaten.
customer.provincestring | nullwajibProvinsi.
customer.postal_codestring | nullwajibKode pos.
itemsarraywajibSelalu tepat satu elemen. Model data AdForm adalah satu pesanan satu produk.
items[].namestringwajibNama produk tanpa penanda bump.
items[].variantstring | nullwajib
items[].quantityintegerwajib
items[].unit_priceinteger | nullwajibnull kalau ada bump.
items[].subtotalintegerwajib
items[].bump_namestring | nullwajibNama produk tambahan, kalau ada.
items[].product_idinteger | nullwajib

#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.created nilainya null; key-nya ada cuma supaya bentuk data seragam.
  • null berarti 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_message bernilai null, 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:

  1. \n adalah baris baru sungguhan setelah JSON di-parse. Kirim sebagai baris baru, bukan sebagai dua karakter \ dan n.
  2. Ada spasi tanpa-putus (U+00A0) di antara Rp dan 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: field total_formatted dan unit_price_formatted memakai gaya lain (Rp275.000, tanpa spasi). Jangan mencampur keduanya dalam satu percakapan, dan pakai total / shipping_cost kalau perlu berhitung.
  3. Jangan mem-parse nomor telepon dari dalam badan pesan. Pakai data.customer.phone.
  4. 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. Ongkir 0 membuat baris Ongkir: 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

Teks
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_secret dibuat 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:

Teks
{"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:

Teks
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
<?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)

JavaScript
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()), dan json_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 = 401 selamanya, tanpa petunjuk apa pun. Yang benar hanya $request->getContent().

Middleware — salin ke app/Http/Middleware/VerifyAdFormSignature.php:

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
<?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:

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
160 detik
25 menit
330 menit
42 jam
56 jam

Maksimum 6 percobaan. Setelah itu kiriman ditandai mati dan tidak dicoba lagi.

Pengecualian yang menghentikan lebih awal:

KondisiPerilaku
Respons 410 GoneBerhenti seketika, tidak ada percobaan lagi.
Respons 404 pada percobaan ke-3Berhenti. Dua 404 pertama masih diulang — alat otomasi sering membalas 404 saat alurnya sedang diedit, lalu 200 semenit kemudian.
Alamat tujuan me-resolve ke jaringan internalBerhenti 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

ParameterNilai
ProtokolHTTPS saja, port 443 saja
SertifikatDiverifikasi. Sertifikat kedaluwarsa atau tidak tepercaya = gagal.
RedirectTidak diikuti
Timeout koneksi / total4 detik / 8 detik dari pekerja antrian. Untuk kiriman kilat yang menyusul submit pesanan: 2 detik / 2 detik.
Kecepatan minimumKoneksi diputus kalau di bawah 64 byte per detik selama 4 detik
Ukuran respons yang dibacaMaksimal 256 KB, sisanya diputus
Endpoint per akun penjualMaksimal 3
Kuota keluar per akun penjual3.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:

  1. Penjual menekan Tes Koneksi di dashboard AdForm.
  2. AdForm mengirim business.test_event yang membawa nilai acak 32 heksadesimal di data.challenge dan di header X-AdForm-Challenge.
  3. Penerima harus membalas 2xx dan 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.
  4. 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

  1. Endpoint HTTPS publik di port 443, dengan sertifikat yang valid, yang menerima POST JSON.
  2. Verifikasi tanda tangan dari raw body. Tolak 401 kalau tidak cocok.
  3. Pantulan data.challenge untuk business.test_event.
  4. Idempotensi berbasis unique_id.
  5. Balasan 2xx cepat, pemrosesan berat di belakang.
  6. Penanganan welcome_message: kirim pada order.created. Di event lain nilainya null — 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.

Ketik minimal dua huruf.

↑↓ pindah Enter buka Esc tutup