Dokumentasi Sakurail

Satu panduan untuk REST API dan webhook: buat tagihan, terima callback, lalu verifikasi signature tanpa pindah halaman.

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

  1. Mulai cepat
  2. Konsep pembayaran: kode unik & status
  3. Endpoint pembayaran
  4. Format error
  5. Webhook
  6. Contoh integrasi end-to-end
  7. Troubleshooting
  8. Batasan saat ini

1. Mulai cepat

Ringkasnya, lima langkah:

  1. Daftar di /register, lalu verifikasi email (kode 6 digit).
  2. Pasang QRIS: buka QRIS Saya, tempel string QRIS statis (atau unggah fotonya), simpan, minta OTP, masukkan kodenya. Status akun harus Aktif.
  3. Buat API key: Kunci API, beri nama, salin sk_live_... (hanya tampil sekali).
  4. Atur webhook: di Pengaturan (atau QRIS Saya), isi URL webhook, lalu salin secret webhook yang ditampilkan untuk verifikasi di servermu.
  5. Buat tagihan lewat POST /api/v1/payments dengan header Idempotency-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

  1. Login ke dashboard Sakurail.
  2. Buka menu API Key (/dashboard/api-keys).
  3. Isi Nama kunci (mis. Server toko utama), klik Buat kunci.
  4. 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).
  5. 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 Bearer diikuti 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 belum ACTIVE, 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 sebagai amount.
  • unique_code — kode unik yang dipilih server (di field JSON bernama unique_code).
  • total_amount — nominal yang harus dibayar pembeli, persis sampai rupiah terakhir.
  • qr_string — string QRIS dinamis yang sudah memuat total_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

StatusArti
PENDINGTagihan dibuat, menunggu pembayaran.
PAIDPembayaran sebesar total_amount terdeteksi dan cocok. Status final.
EXPIREDLewat masa berlaku dan tidak ada pembayaran yang cocok. Status final.
CANCELLEDKamu 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:

MethodPathFungsi
POST/api/v1/paymentsBuat tagihan baru
GET/api/v1/payments/{id}Periksa satu tagihan
POST/api/v1/payments/{id}/cancelBatalkan tagihan PENDING

Semua respons berformat JSON (Content-Type: application/json), termasuk respons error.

3.1 Buat pembayaran — POST /api/v1/payments

Request body (JSON):

FieldTipeWajibKeterangan
account_idintegeryaID akun QRIS milikmu (lihat §1.3). Akun harus milik pemilik API key dan sudah punya QRIS statis.
amountintegeryaNominal dasar dalam rupiah penuh, tanpa desimal. Rentang valid: 100 – 100.000.000.
order_refstringtidakReferensi pesanan dari sistem kamu (mis. nomor invoice). Boleh kosong. Server tidak menjamin keunikannya — jangan jadikan satu-satunya kunci idempotensi di sistem kamu.
feeintegertidakBiaya 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:

HeaderWajibKeterangan
AuthorizationyaBearer sk_live_...
Content-Typeyaapplication/json
Idempotency-Keysangat disarankanMaks 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):

FieldTipeKeterangan
idstringID pembayaran Sakurail, diawali pay_. Simpan untuk polling/cancel dan mencocokkan webhook.
account_idintegerAkun QRIS pemilik tagihan.
order_refstringReferensimu. Tidak muncul di JSON bila kosong.
base_amountintegerNominal dasar (= amount yang kamu kirim).
fee_amountintegerBiaya layanan yang dibebankan ke pembeli (0 bila tidak ada).
unique_codeintegerKode unik yang ditambahkan server (0–999).
total_amountintegerbase_amount + fee_amount + unique_code. Nominal yang harus dibayar persis.
qr_stringstringString QRIS dinamis untuk dirender sebagai QR.
statusstringPENDING / PAID / EXPIRED / CANCELLED.
expires_atstringWaktu kedaluwarsa, RFC 3339 UTC.
paid_atstringWaktu lunas, RFC 3339 UTC. Hanya muncul setelah status PAID.
payment_urlstringTautan halaman bayar siap kirim ke pembeli (lihat §3.4). Pakai ini alih-alih menyusun URL sendiri.
created_atstringWaktu 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-Key yang 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/amount berbeda dibalas 409 —

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_at bila sudah lunas).
  • 404 bila 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 PENDING yang bisa dibatalkan. Tagihan yang sudah PAID/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) — bukan base_amount.
  • Gambar QR yang dirender server dari qr_string tagihan 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):

MethodPathFungsi
GET/p/{id}Halaman bayar (HTML).
GET/p/{id}/statusStatus ringkas {"status","expires_at"} untuk polling halaman.
GET/p/{id}/qr.pngGambar 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:

HTTPPesan (error)Penyebab
400body JSON tidak validBody bukan JSON valid / melebihi batas ukuran.
400amount di luar rentangamount < 100 atau > 100.000.000.
400akun QRIS tidak ditemukanaccount_id tidak ada atau bukan milik pemilik API key.
400akun ini belum punya QRIS statisAkun QRIS belum menyimpan QRIS statis.
400slot nominal unik habis untuk nominal ini1000 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.
401API key tidak validHeader Authorization hilang/salah format, kunci salah, atau sudah dicabut. Berlaku untuk ketiga endpoint.
404pembayaran tidak ditemukanGET ke ID yang tidak ada / bukan milikmu.
402kuota habis: isi ulang untuk membuat tagihan baruSaldo kredit 0 (atau minus). Tambah kredit lewat halaman Kuota. Tidak ada tagihan yang dibuat.
409pembayaran tidak bisa dibatalkanCancel pada tagihan non-PENDING atau bukan milikmu.
409Idempotency-Key sudah dipakai untuk tagihan lainHeader Idempotency-Key yang sama dipakai dengan account_id/amount berbeda.
503fitur pembayaran belum aktifFitur 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):

  1. Buka Pengaturan (/dashboard/settings), temukan akun yang dimaksud. Halaman QRIS Saya (/dashboard/qris) menampilkan form yang sama.
  2. Isi field URL webhook (mis. https://toko-kamu.id/callback/sakurail), klik Simpan webhook. Kosongkan field lalu simpan untuk mematikan webhook akun itu.
  3. 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 http atau https — pakai https untuk produksi.
  • Tidak boleh memuat kredensial (user:pass@host), host tidak boleh localhost, 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.statusKapan dikirim
payment.successPAIDPembayaran cocok dan bertransisi ke PAID.
payment.expiredEXPIREDTagihan 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):

FieldTipeKeterangan
eventstringpayment.success atau payment.expired.
idstringID event. Untuk event nyata, nilainya sama dengan ID pembayaran (pay_...) — pakai sebagai kunci idempotensi.
timestampintegerWaktu event diantrekan, Unix detik (UTC).
data.payment_idstringID pembayaran (sama dengan id).
data.order_refstringorder_ref dari pembuatan tagihan (string kosong bila tidak diisi).
data.account_idintegerID akun QRIS pemilik tagihan.
data.amountintegerNominal yang terbayar/tertagih = total_amount (sudah termasuk kode unik), bukan base_amount.
data.statusstringPAID 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:

HeaderIsi
Content-Typeapplication/json
User-AgentSakurail-Webhook/1.0
X-Sakurail-EventNama 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-TimestampUnix detik saat percobaan kirim ini dibuat (dibuat ulang di setiap percobaan retry, jadi selalu segar).
X-Sakurail-Signaturesha256=<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 prefiks sha256=.
  • Tolak timestamp header yang basi: bila X-Sakurail-Timestamp berbeda 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.

Hasil verifikasi
menghitung…
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):
PercobaanWaktu kirim
1Segera setelah event terjadi (antrean diperiksa berkala, biasanya hitungan detik)
21 menit setelah percobaan 1 gagal
35 menit setelah percobaan 2 gagal
430 menit setelah percobaan 3 gagal
52 jam setelah percobaan 4 gagal
612 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:

  1. Pakai id pada payload (sama dengan data.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.
  2. Jangan mengandalkan order_ref sebagai kunci unik — server tidak menjamin keunikannya.
  3. 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 https publik. Validator server menerima http juga, tapi signature bukan enkripsi — body bisa dibaca/dimodifikasi di jalur http biasa.
  • 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:

  1. Buat tagihan → 2. Tampilkan qr_string + total_amount → 3. Tunggu webhook payment.success (cadangan: polling GET) → 4. Tandai pesanan lunas dari data.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

GejalaPenyebab & perbaikan
Semua request 401 API key tidak validHeader 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 rentangamount harus integer rupiah 100–100.000.000, tanpa titik/koma/desimal.
400 akun QRIS tidak ditemukanaccount_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 statisAkun QRIS belum lengkap di dashboard — lengkapi QRIS statisnya dulu.
Pembeli sudah bayar tapi status tetap PENDINGPaling 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 GETID salah ketik, atau pembayaran milik pemilik API key lain. Simpan id persis dari respons pembuatan.
409 saat cancelTagihan sudah bukan PENDING (sudah lunas/kedaluwarsa/dibatalkan). Ambil status terbaru lewat GET.
Webhook tidak pernah sampaiPeriksa 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 mismatchHampir 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 tidakEvent 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-kaliItu 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 503Fitur 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_url bisa 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 selain payment.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

  1. Akun terverifikasi email, minimal satu akun QRIS berstatus ACTIVE.
  2. API key dibuat dan disimpan di secret manager server merchant (bukan di repo/frontend).
  3. URL webhook publik HTTPS terisi di akun QRIS yang benar, secret disalin ke env server merchant.
  4. Tombol Kirim event uji berhasil (jawaban 2xx dari server merchant).
  5. Satu tagihan kecil dibuat lewat API dengan Idempotency-Key, dibayar persis total_amount, status menjadi PAID, webhook payment.success terverifikasi signature-nya.
  6. Polling GET /api/v1/payments/{id} dipakai sebagai penyelaras cadangan webhook.