Dokumentasi Developer Sakurail
Panduan integrasi REST API + webhook Sakurail untuk merchant: membuat tagihan QRIS dinamis lewat API, memeriksa statusnya, dan menerima notifikasi pembayaran lewat webhook bertanda tangan.
Base URL:
https://sakupayrail.biz.id
Semua contoh di dokumen ini sesuai perilaku server saat ini. Kalau perilaku di lapangan berbeda dari dokumen ini, yang benar adalah servernya — laporkan ke operator Sakurail supaya dokumen ini diperbarui.
Daftar isi
- Mulai cepat
- Konsep pembayaran: kode unik & status
- Endpoint pembayaran
- Format error
- Webhook
- Contoh integrasi end-to-end
- Troubleshooting
- Batasan saat ini
1. Mulai cepat
Ringkasnya, lima langkah:
- Daftar di
/register, lalu verifikasi email (kode 6 digit). - Pasang QRIS: buka QRIS Saya, tempel string QRIS statis (atau unggah fotonya), simpan, minta OTP, masukkan kodenya. Status akun harus Aktif.
- Buat API key: Kunci API, beri nama, salin
sk_live_...(hanya tampil sekali). - Atur webhook: di Pengaturan (atau QRIS Saya), isi URL webhook, lalu salin secret webhook yang ditampilkan untuk verifikasi di servermu.
- Buat tagihan lewat
POST /api/v1/paymentsdengan headerIdempotency-Key.
Setiap akun baru mendapat 20 kredit percobaan; satu kredit = satu tagihan. Sisa kredit terlihat di halaman Kuota dan di header X-Sakurail-Quota-Remaining pada setiap respons sukses.
1.1 Buat API key di dashboard
- Login ke dashboard Sakurail.
- Buka menu API Key (
/dashboard/api-keys). - Isi Nama kunci (mis.
Server toko utama), klik Buat kunci. - Kunci plaintext ditampilkan satu kali saja tepat setelah dibuat, dalam pesan: "Kunci baru (simpan sekarang, tidak ditampilkan lagi): …". Simpan segera di tempat aman (secret manager / env var server kamu).
- Untuk mencabut kunci, klik Cabut kunci pada daftar kunci. Kunci yang dicabut langsung berhenti bekerja (request berikutnya balas
401).
Format kunci:
sk_live_<48 karakter hex>
Contoh bentuknya (bukan kunci asli): sk_live_ab12cd34ef56.... Di server, yang disimpan hanya hash SHA-256 dari kunci — plaintext tidak bisa ditampilkan ulang oleh siapa pun, termasuk operator. Di daftar kunci dashboard, yang terlihat hanya prefiks awal kunci (mis. sk_live_ab12cd…), status (ACTIVE/REVOKED), dan waktu terakhir dipakai.
Satu akun Sakurail boleh membuat beberapa API key (mis. satu per server). Perlakukan API key seperti password: hanya dipakai dari server kamu, jangan pernah ditanam di aplikasi frontend/mobile atau disimpan di repo publik.
1.2 Autentikasi
Semua endpoint /api/v1 memakai header:
Authorization: Bearer sk_live_....
- Skemanya persis
Bearerdiikuti satu spasi, lalu kunci lengkap. - Tanpa header ini, atau kuncinya salah/dicabut, server membalas
401(lihat Format error). - API key menentukan pemilik pembayaran: kamu hanya bisa melihat dan membatalkan pembayaran milik akun Sakurail pemilik kunci tersebut.
1.3 Prasyarat sebelum menerima pembayaran
Sebelum membuat tagihan lewat API, pastikan di dashboard:
- Sudah ada akun QRIS (menu QRIS Saya,
/dashboard/qris) yang QRIS statisnya tersimpan, dan - Akun tersebut sudah terhubung ke provider (status
ACTIVE, selesai OTP). Tagihan tetap bisa dibuat untuk akun yang belumACTIVE, tapi pembayaran masuk tidak akan terdeteksi sampai listener akun itu berjalan.
ID akun QRIS (account_id, angka) bisa dilihat dari nilai pilihan Akun QRIS pada form buat pembayaran di halaman /dashboard/payments, atau tanyakan ke operator bila kamu mengintegrasikan tanpa akses dashboard.
2. Konsep pembayaran: kode unik & status
2.1 Kode unik dan total_amount
Setiap tagihan dibuat dari amount (nominal dasar pesananmu) ditambah fee_amount dari setelan akun (0 bila tanpa fee). Supaya pembayaran dari banyak pembeli tidak tertukar, server menambahkan kode unik terkecil yang masih bebas (mulai dari 0, maksimum 999) pada akun QRIS tersebut:
total_amount = base_amount + fee_amount + unique_code
base_amount— nominal yang kamu kirim sebagaiamount.unique_code— kode unik yang dipilih server (di field JSON bernamaunique_code).total_amount— nominal yang harus dibayar pembeli, persis sampai rupiah terakhir.qr_string— string QRIS dinamis yang sudah memuattotal_amount. Tampilkan string ini sebagai kode QR ke pembeli (render dengan library QR apa pun di sisi kamu).
Pembeli harus membayar total_amount — bukan base_amount. Pembayaran sebesar base_amount saja tidak akan cocok dengan tagihan mana pun. Karena itu di halaman kasir selalu tampilkan nominal bayar = total_amount, dan jangan biarkan pembeli mengubah nominal di aplikasi pembayaran mereka.
Kode unik hanya dijamin tidak bertabrakan antar tagihan berstatus PENDING pada akun QRIS yang sama. Setelah tagihan lama selesai (lunas/kedaluwarsa/dibatalkan), nominal total yang sama bisa dipakai ulang oleh tagihan baru.
2.2 Status pembayaran
| Status | Arti |
|---|---|
PENDING | Tagihan dibuat, menunggu pembayaran. |
PAID | Pembayaran sebesar total_amount terdeteksi dan cocok. Status final. |
EXPIRED | Lewat masa berlaku dan tidak ada pembayaran yang cocok. Status final. |
CANCELLED | Kamu batalkan lewat API/dashboard saat masih PENDING. Status final. |
2.3 Masa berlaku
Setiap tagihan punya expires_at (format RFC 3339 UTC). Masa berlakunya mengikuti pengaturan akun QRIS; nilai bawaan server adalah 900 detik (15 menit) sejak tagihan dibuat.
Catatan toleransi: server masih mencocokkan pembayaran yang masuk sampai 30 menit setelah expires_at sebelum menandai tagihan EXPIRED (mengantisipasi mutasi provider yang telat terindeks). Jadi jangan membuat tagihan pengganti untuk pesanan yang sama hanya karena jam di expires_at sudah lewat — tunggu sampai statusnya benar-benar EXPIRED atau CANCELLED (lihat lewat webhook atau polling GET).
Status berubah menjadi PAID setelah listener Sakurail membaca mutasi dari provider dan mencocokkannya — bukan seketika saat pembeli menekan "bayar". Beri jeda beberapa saat lalu periksa status (webhook adalah jalur utama; polling adalah cadangan).
3. Endpoint pembayaran
Hanya ada tiga endpoint REST untuk merchant:
| Method | Path | Fungsi |
|---|---|---|
POST | /api/v1/payments | Buat tagihan baru |
GET | /api/v1/payments/{id} | Periksa satu tagihan |
POST | /api/v1/payments/{id}/cancel | Batalkan tagihan PENDING |
Semua respons berformat JSON (Content-Type: application/json), termasuk respons error.
3.1 Buat pembayaran — POST /api/v1/payments
Request body (JSON):
| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
account_id | integer | ya | ID akun QRIS milikmu (lihat §1.3). Akun harus milik pemilik API key dan sudah punya QRIS statis. |
amount | integer | ya | Nominal dasar dalam rupiah penuh, tanpa desimal. Rentang valid: 100 – 100.000.000. |
order_ref | string | tidak | Referensi pesanan dari sistem kamu (mis. nomor invoice). Boleh kosong. Server tidak menjamin keunikannya — jangan jadikan satu-satunya kunci idempotensi di sistem kamu. |
fee | integer | tidak | Biaya layanan yang dibebankan ke pembeli untuk tagihan ini (rupiah). Kosong = pakai setelan akun. Tidak bisa lebih besar dari setelan akun; akun yang fee-nya none selalu 0. |
Header yang dikenali:
| Header | Wajib | Keterangan |
|---|---|---|
Authorization | ya | Bearer sk_live_... |
Content-Type | ya | application/json |
Idempotency-Key | sangat disarankan | Maks 128 karakter. Kunci yang sama = tagihan yang sama. Tanpa header ini, setiap retry membuat tagihan baru dan memotong satu kredit. |
Contoh request:
curl -X POST https://sakupayrail.biz.id/api/v1/payments \
-H "Authorization: Bearer sk_live_...." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: INV-1" \
-d '{"account_id":1,"amount":50000,"order_ref":"INV-1"}'
Contoh respons sukses — HTTP 201:
{
"id": "pay_1760000000000abcdef012345",
"account_id": 1,
"order_ref": "INV-1",
"base_amount": 50000,
"unique_code": 1,
"total_amount": 50001,
"qr_string": "00020101021226610018ID.CO.QRIS.WWW011893600915312345678702...",
"status": "PENDING",
"expires_at": "2026-11-01T04:15:00Z",
"created_at": "2026-11-01T04:00:00Z"
}
Field respons (paymentJSON):
| Field | Tipe | Keterangan |
|---|---|---|
id | string | ID pembayaran Sakurail, diawali pay_. Simpan untuk polling/cancel dan mencocokkan webhook. |
account_id | integer | Akun QRIS pemilik tagihan. |
order_ref | string | Referensimu. Tidak muncul di JSON bila kosong. |
base_amount | integer | Nominal dasar (= amount yang kamu kirim). |
fee_amount | integer | Biaya layanan yang dibebankan ke pembeli (0 bila tidak ada). |
unique_code | integer | Kode unik yang ditambahkan server (0–999). |
total_amount | integer | base_amount + fee_amount + unique_code. Nominal yang harus dibayar persis. |
qr_string | string | String QRIS dinamis untuk dirender sebagai QR. |
status | string | PENDING / PAID / EXPIRED / CANCELLED. |
expires_at | string | Waktu kedaluwarsa, RFC 3339 UTC. |
paid_at | string | Waktu lunas, RFC 3339 UTC. Hanya muncul setelah status PAID. |
payment_url | string | Tautan halaman bayar siap kirim ke pembeli (lihat §3.4). Pakai ini alih-alih menyusun URL sendiri. |
created_at | string | Waktu tagihan dibuat, RFC 3339 UTC. |
Ukuran body request dibatasi 1 MB; body yang tidak bisa diparse sebagai JSON dibalas 400.
Idempotensi (penting dengan retry jaringan).
- Kirim
Idempotency-Keyyang unik per tagihan (mis. nomor invoice). Server menyimpan
kunci itu bersama tagihannya.
- Request ulang dengan kunci yang sama tidak membuat tagihan baru, tidak
memotong kredit lagi, dan dibalas 200 dengan body tagihan yang sama. Respons itu membawa header Idempotent-Replay: true.
- Kunci yang sama tetapi
account_id/amountberbeda dibalas409—
jangan pakai satu kunci untuk dua tagihan berbeda.
- Kunci tanpa
Idempotency-Key(header kosong) berarti tidak idempoten: setiap
request adalah tagihan baru.
- Nilai kunci tidak pernah dicatat ke log.
Biaya layanan ke pembeli (fee). Merchant bisa menetapkan tambahan biaya yang dibayar pembeli di atas nominal tagihan — nominal tetap (mis. Rp2.500) atau persen (mis. 0,7%). Setelan ada di dashboard Pengaturan per akun QRIS (form yang sama juga tampil di QRIS Saya), dan berlaku untuk semua tagihan akun itu; parameter fee di atas hanya bisa menurunkannya.
Yang perlu dipahami:
- Ini bukan potongan platform. Dana masuk utuh ke QRIS merchant; fee hanya
menaikkan nominal yang discan pembeli.
- Fee dipagari di nominal dasar. Salah setel (mis. 500% atau Rp999.000 untuk
tagihan Rp10.000) tidak bisa membuat pembeli membayar lebih dari dua kali lipat: fee maksimal sebesar nominalnya.
- Total yang dibayar =
amount+fee+ kode unik. Webhook mengirim
data.amount = total itu.
- Kode unik tetap unik pada total, bukan pada nominal dasar: tagihan
Rp50.000 + fee Rp1.000 dan Rp51.000 + fee Rp0 mendapat total berbeda sehingga tidak mungkin tertukar.
- Fee tidak memengaruhi kuota: tetap satu kredit per tagihan.
Kuota. Setiap tagihan yang berhasil dibuat memotong satu kredit dari saldo akun (potong di awal, bukan saat lunas — biaya pemantauan sudah keluar saat tagihan masuk jalur listener). Setiap respons sukses menyertakan header X-Sakurail-Quota-Remaining berisi sisa kredit. Bila kredit habis, pembuatan tagihan ditolak 402 dan tidak ada tagihan yang tersimpan.
3.2 Periksa pembayaran — GET /api/v1/payments/{id}
curl https://sakupayrail.biz.id/api/v1/payments/pay_1760000000000abcdef012345 \
-H "Authorization: Bearer sk_live_...."
- Sukses: HTTP 200 dengan objek pembayaran yang sama seperti respons pembuatan (termasuk
paid_atbila sudah lunas). 404bila ID tidak ditemukan atau bukan milik pemilik API key (keduanya sengaja tidak dibedakan).
Pakai endpoint ini sebagai cadangan/penyelaras webhook: polling beberapa detik sekali sampai status final (PAID/EXPIRED/CANCELLED). Contoh loop polling ada di §6.
3.3 Batalkan pembayaran — POST /api/v1/payments/{id}/cancel
Tidak perlu body.
curl -X POST https://sakupayrail.biz.id/api/v1/payments/pay_1760000000000abcdef012345/cancel \
-H "Authorization: Bearer sk_live_...."
- Sukses: HTTP 200 dengan objek pembayaran terbaru (status
CANCELLED). - Hanya tagihan
PENDINGyang bisa dibatalkan. Tagihan yang sudahPAID/EXPIRED/CANCELLED, atau bukan milikmu, dibalas 409{"error":"pembayaran tidak bisa dibatalkan"}. (Khusus endpoint ini, ID yang tidak ada juga menghasilkan 409, bukan 404.) - Membatalkan pembayaran tidak mengirim webhook.
3.4 Halaman bayar untuk pembeli — GET /p/{id}
Setiap tagihan punya halaman bayar publik yang bisa dibuka tanpa login, cocok untuk dikirim lewat WhatsApp, email, atau chat toko. Ambil URL-nya dari field payment_url pada respons API (atau tombol Halaman bayar di dashboard).
Isinya:
- Nominal persis yang harus dibayar (
total_amount, sudah termasuk biaya layanan dan kode unik) — bukanbase_amount. - Gambar QR yang dirender server dari
qr_stringtagihan itu (/p/{id}/qr.png), jadi pembeli tidak perlu merender sendiri. - Hitung mundur sampai
expires_at, dan status yang diperbarui otomatis tiap beberapa detik. - Rincian tagihan (nominal, biaya layanan, kode unik,
order_ref) dan tombol salin-tautan / unduh-QR.
Saat status menjadi final, halaman berhenti menampilkan QR: PAID menampilkan konfirmasi lunas, EXPIRED/CANCELLED menyuruh pembeli meminta tautan baru. Ini mencegah orang membayar ke QR yang sudah tidak dicocokkan lagi.
Endpoint pendukung (publik, tanpa API key):
| Method | Path | Fungsi |
|---|---|---|
GET | /p/{id} | Halaman bayar (HTML). |
GET | /p/{id}/status | Status ringkas {"status","expires_at"} untuk polling halaman. |
GET | /p/{id}/qr.png | Gambar QR (PNG) dari qr_string tagihan. |
Halaman dan endpoint ini tidak membocorkan qr_string mentah, order_ref, atau data akun lewat JSON status; ID tagihan berperan sebagai tautan rahasia yang dibagikan merchant, jadi jangan dipublikasikan di tempat umum. Pastikan total_amount yang ditagihkan tetap yang tampil di halaman — halaman ini adalah sumber kebenaran nominal bagi pembeli.
4. Format error
Semua error API berbentuk satu objek:
{"error": "API key tidak valid"}
Daftar kode yang benar-benar dipakai handler:
| HTTP | Pesan (error) | Penyebab |
|---|---|---|
| 400 | body JSON tidak valid | Body bukan JSON valid / melebihi batas ukuran. |
| 400 | amount di luar rentang | amount < 100 atau > 100.000.000. |
| 400 | akun QRIS tidak ditemukan | account_id tidak ada atau bukan milik pemilik API key. |
| 400 | akun ini belum punya QRIS statis | Akun QRIS belum menyimpan QRIS statis. |
| 400 | slot nominal unik habis untuk nominal ini | 1000 nominal total (kode unik 0–999) untuk nominal dasar itu sedang PENDING semua di akun yang sama. |
| 400 | (pesan lain dari engine/store diteruskan apa adanya) | Kegagalan lain saat membuat tagihan, mis. konversi QRIS gagal. |
| 401 | API key tidak valid | Header Authorization hilang/salah format, kunci salah, atau sudah dicabut. Berlaku untuk ketiga endpoint. |
| 404 | pembayaran tidak ditemukan | GET ke ID yang tidak ada / bukan milikmu. |
| 402 | kuota habis: isi ulang untuk membuat tagihan baru | Saldo kredit 0 (atau minus). Tambah kredit lewat halaman Kuota. Tidak ada tagihan yang dibuat. |
| 409 | pembayaran tidak bisa dibatalkan | Cancel pada tagihan non-PENDING atau bukan milikmu. |
| 409 | Idempotency-Key sudah dipakai untuk tagihan lain | Header Idempotency-Key yang sama dipakai dengan account_id/amount berbeda. |
| 503 | fitur pembayaran belum aktif | Fitur pembayaran sedang nonaktif di server (hanya diperiksa saat membuat pembayaran). |
Tidak ada format error lain (tidak ada code, details, atau daftar error per-field). Baca field error sebagai pesan manusiawi untuk log, dan gunakan kode HTTP untuk logika program.
5. Webhook
Webhook adalah notifikasi HTTP POST dari Sakurail ke server kamu saat pembayaran lunas atau kedaluwarsa. Anggap webhook sebagai jalur utama konfirmasi pembayaran, dan polling GET sebagai penyelaras cadangan.
5.1 Mengatur URL dan secret
Secret verifikasi ditampilkan di dashboard Pengaturan pada akun terkait (field Secret webhook akun ini; juga tampil di QRIS Saya), dan tombol Putar secret menggantinya. Secret dibuat otomatis saat akun QRIS ditambahkan, jadi tidak perlu diminta ke operator.
Memutar secret membuat signature lama langsung tidak berlaku. Perbarui nilai di server kamu segera setelah memutar.
Pengaturan webhook bersifat per akun QRIS (bukan per API key):
- Buka Pengaturan (
/dashboard/settings), temukan akun yang dimaksud. Halaman QRIS Saya (/dashboard/qris) menampilkan form yang sama. - Isi field URL webhook (mis.
https://toko-kamu.id/callback/sakurail), klik Simpan webhook. Kosongkan field lalu simpan untuk mematikan webhook akun itu. - Uji kapan saja dengan tombol Kirim event uji (lihat §5.6).
Tentang secret:
- Setiap akun QRIS punya satu secret webhook berformat
whsec_<48 karakter hex>. Secret ini dibuat acak otomatis oleh server saat akun QRIS ditambahkan — berbeda untuk setiap akun. - Secret dipakai sebagai kunci HMAC (§5.5). Perlakukan seperti password: simpan di env var/secret manager server kamu.
Secret ditampilkan apa adanya di dashboard Pengaturan dan bisa diputar sendiri (tombol Putar secret). Jangan memakai secret akun lain — signature tidak akan cocok.
URL webhook divalidasi saat disimpan dan divalidasi ulang setiap kali pengiriman (DNS bisa berubah). Aturannya (anti-SSRF):
- Skema harus
httpatauhttps— pakaihttpsuntuk produksi. - Tidak boleh memuat kredensial (
user:pass@host), host tidak bolehlocalhost, panjang maksimum 2048 karakter. - Host yang ter-resolve ke IP privat, loopback, link-local, atau tidak spesifik ditolak. URL harus bisa dijangkau publik dari internet.
- Sakurail tidak mengikuti redirect: endpoint kamu harus langsung menjawab 2xx, bukan 3xx ke alamat lain.
5.2 Event
Hanya ada dua event nyata:
Event (body event / header X-Sakurail-Event) | data.status | Kapan dikirim |
|---|---|---|
payment.success | PAID | Pembayaran cocok dan bertransisi ke PAID. |
payment.expired | EXPIRED | Tagihan PENDING melewati masa berlaku + toleransi pencocokan tanpa pembayaran yang cocok. |
Tidak ada webhook untuk pembatalan (CANCELLED) dan tidak ada event lain. Webhook hanya diantrekan bila URL webhook akun terisi.
5.3 Payload
Body adalah JSON ringkas (satu baris) persis seperti yang dibuat server. Contoh di bawah ditampilkan terformat agar mudah dibaca — yang dikirim dan ditandatangani adalah byte persis yang diterima server kamu, jadi verifikasi selalu atas body mentah (§5.5).
Struktur payload (webhookBody):
| Field | Tipe | Keterangan |
|---|---|---|
event | string | payment.success atau payment.expired. |
id | string | ID event. Untuk event nyata, nilainya sama dengan ID pembayaran (pay_...) — pakai sebagai kunci idempotensi. |
timestamp | integer | Waktu event diantrekan, Unix detik (UTC). |
data.payment_id | string | ID pembayaran (sama dengan id). |
data.order_ref | string | order_ref dari pembuatan tagihan (string kosong bila tidak diisi). |
data.account_id | integer | ID akun QRIS pemilik tagihan. |
data.amount | integer | Nominal yang terbayar/tertagih = total_amount (sudah termasuk kode unik), bukan base_amount. |
data.status | string | PAID untuk payment.success, EXPIRED untuk payment.expired. |
Contoh payment.success:
{
"event": "payment.success",
"id": "pay_1760000000000abcdef012345",
"timestamp": 1760000000,
"data": {
"account_id": 1,
"amount": 50001,
"order_ref": "INV-1",
"payment_id": "pay_1760000000000abcdef012345",
"status": "PAID"
}
}
Contoh payment.expired:
{
"event": "payment.expired",
"id": "pay_1760000000000abcdef012345",
"timestamp": 1760000900,
"data": {
"account_id": 1,
"amount": 50001,
"order_ref": "INV-1",
"payment_id": "pay_1760000000000abcdef012345",
"status": "EXPIRED"
}
}
5.4 Header pengiriman
Setiap pengiriman webhook (event nyata maupun uji) membawa:
| Header | Isi |
|---|---|
Content-Type | application/json |
User-Agent | Sakurail-Webhook/1.0 |
X-Sakurail-Event | Nama event, mis. payment.success (sama dengan field event di body, termasuk pada event uji). Tetap baca nama event dari body event sebagai sumber utama. |
X-Sakurail-Timestamp | Unix detik saat percobaan kirim ini dibuat (dibuat ulang di setiap percobaan retry, jadi selalu segar). |
X-Sakurail-Signature | sha256=<hmac hex> — lihat §5.5. |
Catatan: X-Sakurail-Timestamp di header bisa berbeda dari field timestamp di body (body = waktu event diantrekan; header = waktu percobaan kirim, dibuat ulang tiap retry). Pemeriksaan anti-replay (§5.5) dan perhitungan signature memakai header.
5.5 Signature dan cara verifikasi
Rumusnya:
X-Sakurail-Signature = "sha256=" + hex( HMAC-SHA256( key = secret "whsec_..." akun, message = TIMESTAMP_HEADER + "." + BODY MENTAH ) )
TIMESTAMP_HEADER adalah nilai header X-Sakurail-Timestamp (angka Unix detik, persis apa adanya — tanpa spasi) diikuti satu titik, lalu body mentah.
Hal yang wajib diperhatikan:
- Kuncinya adalah string secret utuh termasuk prefiks
whsec_, dipakai sebagai byte apa adanya. - Timestamp header IKUT ditandatangani: pesan HMAC =
timestamp + "." + body. Karena itu signature yang bocor tidak bisa diputar ulang dengan timestamp lain — verifikasi tinggal memanggil ulang HMAC dengan timestamp dari header itu sendiri. - Hitung HMAC dari body mentah (raw bytes) yang kamu terima. Jangan parse JSON lalu encode ulang: urutan kunci dan spasi hasil re-encode hampir pasti berbeda dan signature akan gagal.
- Bandingkan dengan perbandingan constant-time (
timingSafeEqual/hash_equals/hmac.Equal), termasuk prefikssha256=. - Tolak timestamp header yang basi: bila
X-Sakurail-Timestampberbeda lebih dari ±5 menit dari waktu server kamu, tolak tanpa memproses. Karena timestamp masuk ke HMAC, pemeriksaan ini sungguh-sungguh mencegah replay (signature lama tidak bisa dilampirkan ke timestamp baru). Samakan jam server (NTP) agar tidak menolak event sah. - Catatan: body pada setiap retry persis sama, tetapi header timestamp (dan karena itu signature) dibuat ulang pada setiap percobaan kirim. Verifikasi memakai timestamp dari header request yang sedang kamu terima.
Vektor uji (pakai untuk menguji kode verifikasimu sebelum live):
secret : whsec_0123456789abcdef0123456789abcdef0123456789abcdef
timestamp : 1760000000
body : {JSON di atas, persis}
signature : sha256=e9f0824ecc277797f822ce8d6ad13df0f142305719263335ccb1fffdf414b2a4
Hasil ini dihitung atas pesan 1760000000. + body (lihat rumus). Contoh kode di bawah sudah diuji terhadap vektor ini: body asli lolos, body yang diubah satu digit ditolak, timestamp basi (>5 menit) ditolak.
Ketiga contoh di bawah sudah diuji terhadap vektor ini: body asli lolos, body yang diubah satu digit ditolak, timestamp basi (>5 menit) ditolak.
Verifier signature
HMAC-SHA256(timestamp + "." + body)Isi empat kolom persis seperti request yang masuk ke servermu. Hasil dihitung ulang saat kamu mengetik.
- Pesan HMAC
- timestamp + "." + body
- Diharapkan
- —
- Diterima
- —
Node.js
import http from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.SAKURAIL_WEBHOOK_SECRET; // whsec_... milik akun QRIS
function verify(rawBody, signature, timestamp, secret, nowSec) {
const age = Math.abs(nowSec - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false; // anti-replay ±5 menit
const expected =
"sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(String(signature || ""));
return a.length === b.length && timingSafeEqual(a, b);
}
const processed = new Set(); // ganti dengan tabel DB untuk produksi
const server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks); // body MENTAH — jangan di-parse dulu
const ok = verify(
raw,
req.headers["x-sakurail-signature"],
req.headers["x-sakurail-timestamp"],
SECRET,
Math.floor(Date.now() / 1000),
);
if (!ok) {
res.writeHead(401).end('{"error":"signature tidak valid"}');
return;
}
const payload = JSON.parse(raw.toString("utf8"));
if (!payload.data?.is_test) {
if (processed.has(payload.id)) {
res.writeHead(200).end('{"ok":true,"duplicate":true}');
return;
}
processed.add(payload.id);
// TODO: proses payload.event / payload.data.payment_id di sini.
// Samakan juga dengan GET /api/v1/payments/{payment_id} bila perlu.
}
res.writeHead(200).end('{"ok":true}');
});
});
server.listen(3000);
Bila memakai Express, daftarkan parser body mentah untuk route webhook (express.raw({ type: "application/json" })) sebelum express.json(), dan verifikasi dari Buffer mentah itu.
PHP
<?php
$secret = getenv('SAKURAIL_WEBHOOK_SECRET'); // whsec_... milik akun QRIS
function sakurail_verify(string $rawBody, ?string $signature, ?string $timestamp, string $secret, int $nowSec): bool {
if ($signature === null || $timestamp === null || !is_numeric($timestamp)) return false;
if (abs($nowSec - (int)$timestamp) > 300) return false; // anti-replay ±5 menit
$expected = "sha256=" . hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$raw = file_get_contents('php://input'); // body MENTAH
$sig = $_SERVER['HTTP_X_SAKURAIL_SIGNATURE'] ?? null;
$ts = $_SERVER['HTTP_X_SAKURAIL_TIMESTAMP'] ?? null;
if (!sakurail_verify($raw, $sig, $ts, $secret, time())) {
http_response_code(401);
echo '{"error":"signature tidak valid"}';
exit;
}
$payload = json_decode($raw, true);
if (!($payload['data']['is_test'] ?? false)) {
$eventId = $payload['id']; // kunci idempotensi — cek ke DB kamu dulu
// TODO: bila $eventId sudah pernah diproses, langsung balas 200 tanpa memproses ulang.
// TODO: proses $payload['event'] / $payload['data']['payment_id'] di sini.
}
http_response_code(200);
echo '{"ok":true}';
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"time"
)
var webhookSecret = os.Getenv("SAKURAIL_WEBHOOK_SECRET") // whsec_... milik akun QRIS
func verifyWebhook(rawBody []byte, signature, timestamp, secret string, now time.Time) bool {
sec, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if d := now.Unix() - sec; d > 300 || d < -300 {
return false // anti-replay ±5 menit
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func sakurailWebhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // body MENTAH
if err != nil {
http.Error(w, "body tidak terbaca", http.StatusBadRequest)
return
}
if !verifyWebhook(raw, r.Header.Get("X-Sakurail-Signature"),
r.Header.Get("X-Sakurail-Timestamp"), webhookSecret, time.Now()) {
http.Error(w, "signature tidak valid", http.StatusUnauthorized)
return
}
var payload struct {
Event string `json:"event"`
ID string `json:"id"`
Data struct {
PaymentID string `json:"payment_id"`
Status string `json:"status"`
IsTest bool `json:"is_test"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
http.Error(w, "JSON tidak valid", http.StatusBadRequest)
return
}
if !payload.Data.IsTest {
// TODO: cek payload.ID di DB kamu; bila sudah diproses, langsung 200.
// TODO: proses payload.Event / payload.Data.PaymentID di sini.
_ = payload
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}
5.6 Event uji (tombol "Kirim event uji")
Tombol Kirim event uji di halaman Pengaturan (atau QRIS Saya) mengirim satu kali, langsung (tidak diantrekan, tidak di-retry) event contoh ke URL webhook akun:
{
"event": "payment.success",
"id": "evt_test",
"timestamp": 1760000000,
"data": {
"account_id": 1,
"amount": 10000,
"is_test": true,
"order_ref": "",
"payment_id": "pay_test",
"status": "PAID"
}
}
Kenali dan abaikan sebagai pembayaran bila data.is_test bernilai true. Dashboard menampilkan kode HTTP jawaban server kamu, jadi event uji adalah cara tercepat memeriksa: URL terjangkau, signature cocok (secret benar), dan endpoint membalas 2xx.
5.7 Kewajiban membalas 2xx dan jadwal retry
- Balas HTTP 2xx (200–299) segera setelah event diterima dan lolos verifikasi. Pemrosesan berat (kirim email, update stok, dsb.) lakukan asinkron setelah membalas.
- Batas waktu per percobaan kirim adalah 10 detik; jawaban lebih lambat dari itu dianggap gagal.
- Jawaban non-2xx, timeout, error koneksi, dan 3xx (redirect tidak diikuti) semuanya dianggap gagal dan memicu retry.
- Event yang gagal diantrekan ulang dengan jadwal tetap berikut (dihitung dari kegagalan sebelumnya):
| Percobaan | Waktu kirim |
|---|---|
| 1 | Segera setelah event terjadi (antrean diperiksa berkala, biasanya hitungan detik) |
| 2 | 1 menit setelah percobaan 1 gagal |
| 3 | 5 menit setelah percobaan 2 gagal |
| 4 | 30 menit setelah percobaan 3 gagal |
| 5 | 2 jam setelah percobaan 4 gagal |
| 6 | 12 jam setelah percobaan 5 gagal |
Setelah percobaan ke-6 gagal, pengiriman berhenti permanen (status internal FAILED). Total ada maksimal 6 kali percobaan dalam rentang ±14,5 jam. Perbaiki endpoint kamu secepatnya bila menerima retry — dan ingat polling GET selalu tersedia sebagai penyelaras, jadi pembayaran tidak hilang hanya karena webhook gagal total.
5.8 Idempotensi: event yang sama bisa datang ulang
Karena ada retry, event yang sama dapat tiba lebih dari sekali. Isi body pada setiap retry persis sama (body disimpan sekali saat event diantrekan; hanya header timestamp — dan karena itu konteks anti-replay — yang diperbarui).
Wajib di sisi kamu:
- Pakai
idpada payload (sama dengandata.payment_id) sebagai kunci idempotensi: simpan ID yang sudah diproses (tabel DB dengan unique constraint), dan bila ID yang sama datang lagi, balas 200 tanpa memproses ulang. - Jangan mengandalkan
order_refsebagai kunci unik — server tidak menjamin keunikannya. - Sebelum menandai pesanan lunas, boleh (dan disarankan untuk nilai besar) menyamakan lewat
GET /api/v1/payments/{payment_id}dengan API key kamu.
5.9 Keamanan webhook
- Pakai URL
httpspublik. Validator server menerimahttpjuga, tapi signature bukan enkripsi — body bisa dibaca/dimodifikasi di jalurhttpbiasa. - Secret berbeda per akun QRIS. Bila satu akun bocor secretnya, akun lain tidak ikut terdampak — tapi signature akun itu bisa dipalsukan siapa pun yang memegang secretnya. Putar sendiri lewat tombol Putar secret di halaman Pengaturan; signature lama langsung tidak berlaku.
- Selalu verifikasi signature sebelum memproses isi body, dan tolak timestamp di luar ±5 menit.
- Jangan mencatat secret atau API key di log.
6. Contoh integrasi end-to-end
Alur kasir online yang umum:
- Buat tagihan → 2. Tampilkan
qr_string+total_amount→ 3. Tunggu webhookpayment.success(cadangan: pollingGET) → 4. Tandai pesanan lunas daridata.payment_id/order_ref.
export SAKURAIL_API_KEY="sk_live_...."
export BASE="https://sakupayrail.biz.id"
# 1) Buat tagihan Rp50.000 untuk invoice INV-1 di akun QRIS 1
CREATE=$(curl -s -X POST "$BASE/api/v1/payments" \
-H "Authorization: Bearer $SAKURAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"account_id":1,"amount":50000,"order_ref":"INV-1"}')
echo "$CREATE"
# Ambil "id", "qr_string", dan "total_amount" dari JSON di atas.
# Render qr_string sebagai gambar QR dan tampilkan total_amount
# sebagai nominal yang HARUS dibayar persis.
Cadangan polling bila webhook belum terpasang (webhook tetap jalur yang disarankan):
PAYMENT_ID="pay_1760000000000abcdef012345"
while true; do
STATUS=$(curl -s "$BASE/api/v1/payments/$PAYMENT_ID" \
-H "Authorization: Bearer $SAKURAIL_API_KEY" \
| grep -o '"status":"[A-Z]*"' | cut -d'"' -f4)
echo "status: $STATUS"
case "$STATUS" in PAID|EXPIRED|CANCELLED) break ;; esac
sleep 5
done
Di server penerima webhook, ikuti pola §5.5: baca body mentah → periksa timestamp ±5 menit → verifikasi HMAC constant-time → cek idempotensi id → proses payment.success/payment.expired → balas 200.
7. Troubleshooting
| Gejala | Penyebab & perbaikan |
|---|---|
Semua request 401 API key tidak valid | Header harus persis Authorization: Bearer sk_live_... (satu spasi, huruf Bearer persis). Periksa kunci tidak terpotong/tertukar, belum dicabut di /dashboard/api-keys, dan tidak ada spasi ganda setelah Bearer. |
400 amount di luar rentang | amount harus integer rupiah 100–100.000.000, tanpa titik/koma/desimal. |
400 akun QRIS tidak ditemukan | account_id salah, atau akun milik akun Sakurail lain (API key hanya melihat akun pemiliknya). Samakan dengan ID di dashboard (§1.3). |
400 akun ini belum punya QRIS statis | Akun QRIS belum lengkap di dashboard — lengkapi QRIS statisnya dulu. |
Pembeli sudah bayar tapi status tetap PENDING | Paling sering: yang dibayar base_amount, bukan total_amount (selisih kode unik). Tampilkan selalu total_amount. Bila nominal sudah persis, beri waktu listener mencocokkan mutasi lalu cek GET; jangan buat tagihan pengganti sebelum status final (§2.3). |
404 pembayaran tidak ditemukan saat GET | ID salah ketik, atau pembayaran milik pemilik API key lain. Simpan id persis dari respons pembuatan. |
409 saat cancel | Tagihan sudah bukan PENDING (sudah lunas/kedaluwarsa/dibatalkan). Ambil status terbaru lewat GET. |
| Webhook tidak pernah sampai | Periksa berurutan: URL terisi di akun QRIS yang benar → URL publik https dan tidak ter-resolve ke IP privat/loopback (ditolak anti-SSRF) → endpoint membalas 2xx langsung (bukan redirect 3xx) dalam <10 detik → uji dengan tombol Kirim event uji dan lihat kode jawaban yang dilaporkan dashboard. Ingat: sesudah 6 percobaan gagal, pengiriman berhenti (§5.7) — verifikasi status akhir lewat polling GET. |
| Signature selalu mismatch | Hampir selalu salah satu dari: (1) HMAC dihitung dari JSON hasil parse lalu re-encode — wajib body mentah; (2) secret akun QRIS lain / secret tanpa prefiks whsec_; (3) membandingkan tanpa prefiks sha256=; (4) secret tertukar dengan API key sk_live_.... Uji dulu dengan vektor uji §5.5. |
| Event uji diterima tapi event nyata tidak | Event uji event-nya payment.success dengan data.is_test: true. Pastikan handler kamu membaca event dari body dan tidak memproses is_test sebagai pembayaran. Event nyata hanya terkirim bila pembayaran benar-benar cocok ke PAID/EXPIRED pada akun yang URL webhook-nya terisi. |
| Event yang sama diterima berkali-kali | Itu retry (§5.7) atau event sah yang balasannya terlambat/non-2xx. Terapkan idempotensi kunci id (§5.8) dan balas 200 segera setelah verifikasi. |
Request pembuatan dibalas 503 | Fitur pembayaran sedang nonaktif di server. Ini bukan kesalahan request kamu — coba lagi nanti / hubungi operator. |
8. Batasan saat ini
Supaya tidak ada asumsi keliru, hal berikut belum tersedia di API saat ini:
- Tidak ada endpoint daftar/list pembayaran lewat API (hanya per-ID), dan tidak ada filter/paginasi.
- Halaman bayar publik tidak punya autentikasi pembeli (tidak ada akun pembeli); siapa pun yang memegang
payment_urlbisa melihat nominal dan status tagihan itu. - Tidak ada refund, payout, transfer, atau mutasi/rekening koran lewat API.
- Tidak ada API key sandbox/test terpisah — kunci hanya
sk_live_.... Untuk pengujian webhook, pakai tombol Kirim event uji (§5.6). - Tidak ada webhook untuk pembatalan (
CANCELLED) dan tidak ada event selainpayment.success/payment.expired. - Secret webhook ditampilkan apa adanya di dashboard Pengaturan (field Secret webhook akun ini), dan bisa diputar sendiri lewat tombol Putar secret — tidak perlu operator. Signature lama langsung tidak berlaku setelah diputar.
- Payment expiry per akun saat ini mengikuti nilai server (bawaan 900 detik); belum ada endpoint untuk mengubahnya lewat API.
9. Checklist go-live
- Akun terverifikasi email, minimal satu akun QRIS berstatus
ACTIVE. - API key dibuat dan disimpan di secret manager server merchant (bukan di repo/frontend).
- URL webhook publik HTTPS terisi di akun QRIS yang benar, secret disalin ke env server merchant.
- Tombol Kirim event uji berhasil (jawaban 2xx dari server merchant).
- Satu tagihan kecil dibuat lewat API dengan
Idempotency-Key, dibayar persistotal_amount, status menjadiPAID, webhookpayment.successterverifikasi signature-nya. - Polling
GET /api/v1/payments/{id}dipakai sebagai penyelaras cadangan webhook.