Webhook
Webhook adalah cara Reader mengabari sistem Anda bahwa tagihan lunas, payout
terkirim, atau ada kejadian lain. Reader mengirim POST berisi JSON ke
callback_url aplikasi Anda, lalu menunggu balasan ack.
Halaman ini memuat semua aturan pengiriman. Contoh kode lengkap ada di Contoh kode.
Ringkasan dalam 30 detik
- Terima
POSTdicallback_url. - Verifikasi
X-Reader-Signaturedari body mentah, dengan toleransi waktu 5 menit. - Cek
event_id. Bila sudah pernah diproses, balasduplicate. - Proses event secepatnya. Pekerjaan berat dipindah ke antrean.
- Dalam 10 detik, balas HTTP 2xx dengan:
{ "event_id": "evt_01j8zqa2b3c4d5e6f7g8h9j0km", "status": "accepted" }
Balasan 200 tanpa ack yang benar dianggap gagal dan dikirim ulang.
Menyiapkan endpoint
Atur di dashboard, menu Aplikasi:
| Pengaturan | Keterangan |
|---|---|
| Callback URL | Wajib HTTPS, bisa diakses publik, dan tidak mengarah ke IP privat atau lokal. Dicek saat disimpan dan saat setiap pengiriman |
| Webhook secret | whsec_…, dipakai untuk memverifikasi tanda tangan. Ditampilkan sekali saat aplikasi dibuat atau secret dirotasi |
| Wajib ack | Menyala secara bawaan. Bila dimatikan, balasan 2xx apa pun dianggap berhasil. Tidak disarankan (lihat kenapa) |
| Kirim event mutasi tanpa pasangan | mutation.unmatched. Mati secara bawaan |
| Kirim event konfirmasi tertunda | payment.confirmation_delayed dan payout.confirmation_delayed. Mati secara bawaan |
| Kirim event kedaluwarsa | payment.expired dan payout.expired. Mati secara bawaan |
Satu aplikasi punya satu callback URL. Event dari kunci uji dan kunci live
dikirim ke URL yang sama, dibedakan oleh field test.
Katalog event
| Event | Kapan dikirim | Wajib ditangani? |
|---|---|---|
payment.paid |
Tagihan lunas: normal, telat (late: true), setelah dibatalkan (paid_after_cancel: true), atau ditandai manual (match_type: manual_unverified) |
Ya |
payment.duplicate |
Uang masuk lagi untuk tagihan yang sudah lunas | Ya |
payment.verified |
Tagihan yang ditandai lunas manual terbukti oleh notifikasi bank | Opsional |
payment.expired |
Tagihan kedaluwarsa. Belum final selama masa jeda 48 jam. Hanya bila diaktifkan | Opsional |
payment.confirmation_delayed |
Rekening tagihan menjadi tidak terpantau. Hanya bila diaktifkan | Opsional |
payout.sent |
Payout terkirim, termasuk telat dan hasil cek penerima (recipient_check) |
Ya, bila memakai payout |
payout.duplicate |
Uang keluar lagi untuk payout yang sudah terkirim | Ya, bila memakai payout |
payout.reversed |
Bank mengembalikan transfer payout | Ya, bila memakai payout |
payout.verified |
Payout yang ditandai terkirim manual terbukti oleh notifikasi bank | Opsional |
payout.expired |
Payout kedaluwarsa. Belum final selama masa jeda. Hanya bila diaktifkan | Opsional |
payout.confirmation_delayed |
Rekening sumber payout menjadi tidak terpantau. Hanya bila diaktifkan | Opsional |
mutation.unmatched |
Uang masuk tanpa tagihan yang cocok. Hanya bila diaktifkan | Opsional |
account.terminated |
Layanan akun diputus admin. Webhook berhenti setelah event ini | Ya |
ping |
Tombol Uji endpoint di dashboard | Balas ack |
Balas accepted untuk jenis event yang tidak Anda kenal, supaya integrasi
Anda tetap berjalan bila kelak ada jenis event baru.
Bentuk request
POST <callback_url>
Content-Type: application/json
User-Agent: RAGH-Reader-Webhook/1.0
X-Reader-Event-Id: evt_01j8zqa2b3c4d5e6f7g8h9j0km
X-Reader-Timestamp: 1790566265
X-Reader-Signature: sha256=5f2b9c…
| Header | Isi |
|---|---|
X-Reader-Event-Id |
Sama dengan event_id di body |
X-Reader-Timestamp |
Waktu kirim, Unix detik. Berbeda di setiap percobaan kirim |
X-Reader-Signature |
sha256= + HMAC-SHA256 heksadesimal huruf kecil. Berbeda di setiap percobaan kirim |
Body selalu berbentuk amplop yang sama:
{
"event": "payment.paid",
"event_id": "evt_01j8zqa2b3c4d5e6f7g8h9j0km",
"api_version": "2026-09-28",
"created_at": "2026-09-28T10:31:05+07:00",
"test": false,
"data": { }
}
| Field | Keterangan |
|---|---|
event |
Jenis event |
event_id |
ID unik event. Kunci idempotensi Anda |
api_version |
Versi bentuk data |
created_at |
Waktu event dibuat (bukan waktu kirim) |
test |
true untuk data mode uji. Jangan memenuhi pesanan sungguhan dari event uji |
data |
Isi event, lihat di bawah |
Body sebuah event sama persis di setiap percobaan kirim. Yang berubah hanya header timestamp dan tanda tangan.
Isi data per event
| Event | Isi data |
|---|---|
payment.paid |
Objek tagihan + mutation |
payment.duplicate |
Objek tagihan (yang sudah lunas) + duplicate_mutation |
payment.verified, payment.expired, payment.confirmation_delayed |
Objek tagihan |
payout.sent |
Objek payout + mutation |
payout.duplicate |
Objek payout (yang sudah terkirim) + duplicate_mutation |
payout.reversed |
Objek payout (status reversed) + reversal_mutation |
payout.verified, payout.expired, payout.confirmation_delayed |
Objek payout |
mutation.unmatched |
Objek mutasi, seperti di GET /api/v1/mutations |
account.terminated |
merchant_id, reason, at |
ping |
message, app_id |
mutation, duplicate_mutation, dan reversal_mutation adalah ringkasan
mutasi:
{
"id": "mut_01j8zqb7r2s3t4v5w6x7y8z9a0",
"counterparty_name": "BUDI SANTOSO",
"transaction_at": "2026-09-28T10:31:00+07:00",
"received_at": "2026-09-28T10:31:04+07:00"
}
Pada payment.paid dan payout.sent yang ditandai manual dari dashboard,
mutation bernilai null.
Contoh lengkap payment.paid:
{
"event": "payment.paid",
"event_id": "evt_01j8zqa2b3c4d5e6f7g8h9j0km",
"api_version": "2026-09-28",
"created_at": "2026-09-28T10:31:05+07:00",
"test": false,
"data": {
"id": "pay_01j8zq5v3m7c2k9x4h6t1r0bna",
"tx_number": "TKO-260928-0042",
"reference_id": "ORDER-123",
"amount": 200000,
"unique_code": 123,
"total_amount": 200123,
"bank": { "code": "BCA", "name": "BCA", "account_number": "1234567890", "account_holder": "PT CONTOH JAYA" },
"status": "paid",
"confirmation_delayed": false,
"late": false,
"paid_after_cancel": false,
"match_type": "auto",
"test": false,
"description": "Kursus A",
"customer": { "ref": "CUST-9", "name": "Budi", "contact": "081234567890" },
"tags": ["produk:kursus-a", "kanal:instagram"],
"metadata": { "cart_id": "C-77" },
"expires_at": "2026-09-29T10:15:00+07:00",
"paid_at": "2026-09-28T10:31:04+07:00",
"expired_at": null,
"cancelled_at": null,
"created_at": "2026-09-28T10:15:00+07:00",
"payment_url": "https://payment-reader.ragh.co.id/pay/pay_01j8zq5v3m7c2k9x4h6t1r0bna",
"mutation": {
"id": "mut_01j8zqb7r2s3t4v5w6x7y8z9a0",
"counterparty_name": "BUDI SANTOSO",
"transaction_at": "2026-09-28T10:31:00+07:00",
"received_at": "2026-09-28T10:31:04+07:00"
}
}
}
Contoh account.terminated:
{
"event": "account.terminated",
"event_id": "evt_01j8zz9x8w7v6t5s4r3q2p1n0m",
"api_version": "2026-09-28",
"created_at": "2026-09-30T09:00:00+07:00",
"test": false,
"data": { "merchant_id": "mer_01j8zk3m4n5p6q7r8s9t0v1w2x", "reason": "Pelanggaran syarat layanan", "at": "2026-09-30T09:00:00+07:00" }
}
Setelah account.terminated, semua kunci API dicabut dan tagihan pending
dibatalkan. Hentikan pembuatan tagihan dan jangan arahkan pembeli ke halaman
bayar lagi.
Verifikasi tanda tangan
Tanda tangan membuktikan request benar-benar dari Reader dan isinya tidak diubah.
X-Reader-Signature = "sha256=" + hex( HMAC_SHA256( webhook_secret, timestamp + "." + body ) )
Langkah verifikasi:
- Ambil body mentah persis seperti diterima, sebelum di-parse. Jangan mem-parse lalu meng-encode ulang JSON: urutan field, spasi, dan escape bisa berubah, sehingga tanda tangan tidak cocok.
- Ambil header
X-Reader-Timestamp. Tolak bila bukan angka, atau selisihnya dengan jam server Anda lebih dari 300 detik. Ini mencegah request lama diputar ulang. - Hitung HMAC-SHA256 dengan kunci = webhook secret (seluruh string
whsec_…, apa adanya) dan pesan =timestamp + "." + body. - Bentuk
"sha256=" + hex huruf kecil, lalu bandingkan dengan headerX-Reader-Signaturememakai perbandingan waktu-konstan (hash_equals,crypto.timingSafeEqual,hmac.Equal,hmac.compare_digest). - Bila tidak cocok, balas
401. Reader akan mengirim ulang sesuai jadwal.
Pastikan jam server Anda tersinkron (NTP). Jam yang meleset lebih dari 5 menit membuat semua webhook ditolak.
Kontrak ack
Setiap webhook wajib dibalas dalam 10 detik dengan HTTP 2xx dan body JSON:
{ "event_id": "evt_01j8zqa2b3c4d5e6f7g8h9j0km", "status": "accepted" }
event_idharus sama persis denganevent_iddi body webhook.statussalah satu dari empat nilai di bawah.reason(string) opsional, dipakai bersamarejected.
status |
Arti | Tindakan Reader |
|---|---|---|
accepted |
Diterima dan (akan) diproses | Selesai. Event delivered |
duplicate |
Event ini sudah pernah Anda proses | Selesai. Event delivered |
rejected |
Anda menolak dengan sadar, misalnya "order tidak ditemukan". Sertakan reason |
Tidak dikirim ulang. Event rejected, dan kejadiannya masuk daftar perlu tindakan beserta alasannya. Uangnya tetap tercatat |
retry |
Minta dikirim ulang nanti | Dikirim ulang sesuai jadwal |
Contoh menolak:
{ "event_id": "evt_01j8zqa2b3c4d5e6f7g8h9j0km", "status": "rejected", "reason": "order ORDER-123 tidak ditemukan" }
Pakai rejected hanya bila event memang tidak bisa diproses, dan perlu dilihat
manusia. Untuk kejadian yang bisa Anda tangani sendiri (misalnya pesanan sudah
dipenuhi), balas accepted atau duplicate.
Kenapa ack wajib
Banyak server membalas 200 untuk URL yang salah: halaman beranda, halaman
login, atau rute cadangan aplikasi SPA. Tanpa ack, webhook yang tidak pernah
diproses akan tampak "sukses". Dengan ack, Reader tahu pasti bahwa kode
webhook Anda yang membalas.
Anda bisa mematikan kewajiban ack per aplikasi di dashboard. Setelah itu, balasan 2xx apa pun dianggap berhasil, termasuk dari URL yang salah.
Pemetaan respons
Setiap percobaan kirim dicatat dengan kode di dashboard. Kode yang sama muncul
di last_error_code dan last_delivery.error_code pada
GET /api/v1/webhook-events.
| Respons endpoint Anda | Kode | Tindakan Reader | Petunjuk |
|---|---|---|---|
2xx + ack accepted / duplicate |
DELIVERED |
Selesai | — |
2xx + ack rejected |
REJECTED |
Tidak diulang. Masuk perlu tindakan | Aplikasi Anda menolak event ini dengan sadar |
2xx + ack retry |
RETRY_REQUESTED |
Diulang sesuai jadwal | Aplikasi Anda meminta dikirim ulang |
| 2xx tanpa ack yang valid | INVALID_ACK |
Diulang | Endpoint membalas 2xx tanpa ack yang benar. Kemungkinan URL-nya salah, atau kode belum membalas {"event_id": "...", "status": "accepted"} |
| 3xx | REDIRECT |
Redirect tidak diikuti. Diulang | Perbarui callback URL ke alamat tujuan redirect. Sering terjadi karena http→https atau garis miring di akhir URL |
| 401 / 403 | SIGNATURE_REJECTED |
Diulang | Tanda tangan ditolak. Periksa webhook secret yang dipakai aplikasi |
| 404 | NOT_FOUND |
Diulang | Rute webhook tidak ada |
| 408 / 429 | BUSY |
Diulang, mengikuti Retry-After |
Server sibuk |
| 410 | GONE |
Endpoint dinonaktifkan. Semua pengiriman ke aplikasi ini dihentikan, dan Anda dikabari | Aktifkan lagi dari dashboard setelah endpoint siap |
| 4xx lain (400, 422, …) | HTTP_4XX |
Diulang | Payload ditolak. Periksa versi API dan validasi di aplikasi Anda |
| 5xx | HTTP_5XX |
Diulang | Error di server aplikasi Anda |
| Tidak ada balasan dalam 10 detik | TIMEOUT |
Diulang | Balas dulu, lalu proses belakangan di antrean |
| Nama domain tidak ditemukan | DNS_ERROR |
Diulang | Periksa DNS domain callback |
| Masalah sertifikat | TLS_ERROR |
Diulang | Periksa sertifikat TLS endpoint (kedaluwarsa, rantai tidak lengkap, nama tidak cocok) |
| Koneksi gagal | CONNECTION_ERROR |
Diulang | Server mati, port tertutup, atau firewall menolak |
| Callback URL kosong | NO_CALLBACK_URL |
Diulang | Isi callback URL di pengaturan aplikasi |
| URL ditolak | BLOCKED_URL |
Diulang | Callback URL wajib HTTPS dan tidak boleh mengarah ke IP privat |
Ack dianggap valid bila: status HTTP 2xx, body berupa objek JSON,
event_id sama persis, dan status salah satu dari accepted, duplicate,
rejected, retry. Header Content-Type balasan tidak diperiksa.
Yang disimpan untuk setiap percobaan: waktu, kode HTTP, 2 KB pertama body
balasan, header Content-Type, Retry-After, dan Location, durasi, kode
error, dan pemicunya. Detail percobaan disimpan 90 hari.
Jadwal kirim ulang
percobaan 1 : seketika
percobaan 2 : 1 menit setelah percobaan 1 gagal
percobaan 3 : 5 menit setelah percobaan 2 gagal
berikutnya : tiap 15 menit
berhenti : 72 jam sejak percobaan pertama → status failed
- Bila balasan membawa header
Retry-Afterdalam detik, percobaan berikutnya tidak lebih cepat dari nilai itu, dengan batas 1 jam. - Jadwal berjalan per menit, jadi waktunya bisa bergeser hingga sekitar satu menit.
- Event
failedtidak dikirim otomatis lagi, tetapi masih bisa dikirim ulang manual kapan saja.
Selama gagal terus, pemilik dan admin akun dikabari lewat email (dan WhatsApp, bila nomor HP terisi di profil) setelah 1 jam, lalu setiap 24 jam selama belum pulih.
Pemutus sirkuit
Bila endpoint Anda gagal 3 kali berturut-turut, Reader membuka "sirkuit" untuk endpoint itu, supaya server Anda tidak dibanjiri request yang pasti gagal:
- Event lain untuk endpoint itu tidak dicoba satu per satu.
- Setiap 15 menit, hanya satu event tertua yang dikirim sebagai penguji.
- Begitu penguji berhasil, sirkuit ditutup, lalu semua event yang tertunda dikirim berurutan sesuai waktu dibuat.
Balasan ack apa pun, termasuk rejected dan retry, membuktikan endpoint
bisa dihubungi, sehingga tidak dihitung sebagai kegagalan. Kirim ulang manual
yang berhasil juga langsung menutup sirkuit.
Batas 72 jam tetap dihitung per event selama sirkuit terbuka.
Kirim ulang manual
Setelah memperbaiki server, Anda tidak perlu menunggu jadwal otomatis.
| Cara | Cakupan | Aturan |
|---|---|---|
Kirim ulang sekarang (per event), di dashboard atau lewat POST /api/v1/webhook-events/{id}/resend |
Satu event, berapa pun umurnya, termasuk yang failed atau rejected |
Dikirim seketika, melewati jadwal, pemutus sirkuit, dan jeda. Bila berhasil, event menjadi delivered dan sirkuit endpoint ditutup, sehingga antrean lain ikut mengalir. Bila gagal, dicatat sebagai percobaan manual dan jadwal otomatis tetap berjalan; batas 72 jam tidak diperpanjang. Paling banyak 1 kali per 60 detik per event |
| Kirim ulang semua yang gagal, di dashboard | Hanya event yang gagal atau sedang menunggu jadwal ulang, yang dibuat dalam 30 menit terakhir, untuk aplikasi yang sedang dilihat | Dashboard menampilkan jumlahnya lebih dulu. Event dikirim berurutan sesuai waktu dibuat, paling banyak 5 per detik. Tombol bisa dipakai lagi 5 menit setelah pemakaian terakhir |
Kenapa tombol massal dibatasi 30 menit: ratusan event lama yang dikirim sekaligus bisa menjatuhkan server Anda yang baru pulih, dan lebih mungkin diproses dua kali oleh kode yang belum idempoten. Event yang lebih tua tetap diurus jadwal otomatis, atau bisa dikirim ulang satu per satu.
Jeda pengiriman
Saat maintenance, Anda bisa menjeda pengiriman dari dashboard, menu Webhook.
- Event tetap dibuat dan disimpan, lalu dikirim setelah jeda dibuka.
- Batas 72 jam tetap dihitung selama jeda.
- Kirim ulang manual per event tetap bisa dipakai selama jeda.
Endpoint dinonaktifkan (410)
Membalas 410 Gone berarti "endpoint ini sudah tidak ada". Reader langsung:
- menonaktifkan endpoint aplikasi itu dan menghentikan semua pengiriman otomatis;
- mengabari Anda lewat email (dan WhatsApp, bila nomor HP terisi di profil).
Event yang belum terkirim tetap disimpan. Setelah endpoint siap, tekan Aktifkan lagi di dashboard. Event yang menunggu dikirim di jadwal berikutnya, selama belum lewat 72 jam sejak percobaan pertamanya. Jangan pernah membalas 410 untuk kesalahan sementara.
Rotasi webhook secret
Merotasi secret dilakukan dari dashboard dan meminta OTP email. Secret baru langsung dipakai untuk semua percobaan kirim berikutnya, termasuk kirim ulang event lama.
Supaya tidak ada webhook yang ditolak saat pergantian:
- Ubah kode Anda agar sementara menerima tanda tangan dari secret lama atau secret baru.
- Rotasi secret di dashboard, lalu pasang secret baru di server.
- Setelah beberapa menit, hapus secret lama dari kode.
Webhook yang sempat ditolak (SIGNATURE_REJECTED) akan dikirim ulang sesuai
jadwal.
Urutan dan idempotensi
- Urutan event tidak dijamin. Event yang dikirim ulang bisa tiba setelah
event yang lebih baru. Contoh:
payment.expiredyang tertunda bisa tiba setelahpayment.paid. Jangan pernah menurunkan status pesanan yang sudah lunas karena event yang datang belakangan. Bila ragu, baca status terbaru denganGET /api/v1/payments/{id}. - Satu event bisa tiba lebih dari sekali, misalnya bila ack Anda tidak
sampai karena timeout. Simpan
event_idyang sudah diproses dengan indeks unik, dan balasduplicateuntuk event yang berulang. - Idealnya, catat
event_iddan perubahan pesanan dalam satu transaksi database, supaya keduanya tidak pernah setengah jadi. - Satu tagihan bisa menghasilkan beberapa event berbeda (misalnya
payment.paidlalupayment.verified, ataupayment.paidlalupayment.duplicate). Masing-masing punyaevent_idsendiri.
Menangani telat dan dobel
| Event dan tanda | Artinya | Yang disarankan |
|---|---|---|
payment.paid, late: false |
Lunas tepat waktu | Penuhi pesanan |
payment.paid, late: true |
Pembeli membayar setelah batas waktu, dalam masa jeda 48 jam | Penuhi pesanan. Pembeli sudah membayar. Bila stok habis, refund dan tetap balas accepted |
payment.paid, paid_after_cancel: true |
Tagihan sudah dibatalkan, tetapi uang tetap masuk | Aktifkan kembali dan penuhi pesanan, atau refund. Kejadian ini juga ada di daftar perlu tindakan |
payment.paid, match_type: manual_unverified |
Ditandai lunas manual dari dashboard, notifikasi bank belum datang | Proses seperti lunas biasa. payment.verified menyusul bila notifikasinya datang |
payment.duplicate |
Uang masuk lagi untuk tagihan yang sudah lunas | Jangan penuhi pesanan dua kali. Catat untuk refund |
payment.expired |
Kedaluwarsa, tetapi belum final selama 48 jam | Boleh tampilkan "menunggu pembayaran kedaluwarsa". Jangan hapus pesanan, dan jangan batalkan pesanan yang sudah dipenuhi |
payout.sent, recipient_check: "mismatch" |
Uang sudah keluar, tetapi penerima di notifikasi bank berbeda | Tandai pencairan selesai, lalu minta admin memeriksanya |
payout.duplicate |
Penerima dibayar dua kali | Minta dana kembali atau anggap pembayaran berikutnya |
payout.reversed |
Bank mengembalikan transfer | Tandai pencairan belum selesai. Untuk transfer ulang, buat payout baru dengan reference_id yang sama |
Uji endpoint
Tombol Uji endpoint di halaman aplikasi mengirim event ping:
{
"event": "ping",
"event_id": "evt_01j8zv4w5x6y7z8a9b0c1d2e3f",
"api_version": "2026-09-28",
"created_at": "2026-09-28T09:00:00+07:00",
"test": false,
"data": { "message": "Uji webhook dari RAGH Reader", "app_id": "app_01j8zk7p8q9r0s1t2v3w4x5y6z" }
}
Balas dengan ack seperti biasa. Hasilnya langsung tampil di dashboard, lengkap dengan kode respons dan body balasan Anda.
Untuk menguji alur lengkap tagihan, pakai kunci uji dan
POST /api/v1/test/simulate.
Daftar IP pengirim
Daftar IP pengirim webhook belum dipublikasikan. Halaman ini akan memuatnya setelah tersedia. Sementara itu, jangan membatasi endpoint hanya dengan allowlist IP. Verifikasi tanda tangan adalah perlindungan utamanya.
Checklist sebelum go-live
- Callback URL memakai HTTPS dengan sertifikat valid, dan tidak me-redirect.
- Tanda tangan diverifikasi dari body mentah, dengan perbandingan waktu-konstan.
- Timestamp ditolak bila selisihnya lebih dari 5 menit, dan jam server tersinkron NTP.
- Balasan berupa JSON ack dengan
event_idyang sama, dalam 10 detik. - Pekerjaan berat (email, pembuatan akses, sinkronisasi) dipindah ke antrean.
-
event_iddisimpan dengan indeks unik. Event berulang dibalasduplicate. - Event dengan
test: truetidak memproses pesanan sungguhan. -
payment.paiddenganlate: truetetap memenuhi pesanan. -
payment.duplicatetidak memenuhi pesanan dua kali, dan dicatat untuk refund. -
payment.expiredtidak dianggap final, dan tidak membatalkan pesanan yang sudah dipenuhi. - Status pesanan lunas tidak pernah diturunkan oleh event yang datang belakangan.
- Jenis event yang tidak dikenal dibalas
accepted. -
rejectedhanya dipakai untuk kasus yang perlu dilihat manusia, denganreasonyang jelas. - Tombol Uji endpoint di dashboard menghasilkan
DELIVERED. - Bila memakai payout:
payout.sent,payout.duplicate, danpayout.reversedditangani, termasukrecipient_check: "mismatch". -
account.terminatedmenghentikan pembuatan tagihan baru.