# 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](#1-mulai-cepat)
2. [Konsep pembayaran: kode unik & status](#2-konsep-pembayaran-kode-unik--status)
3. [Endpoint pembayaran](#3-endpoint-pembayaran)
4. [Format error](#4-format-error)
5. [Webhook](#5-webhook)
6. [Contoh integrasi end-to-end](#6-contoh-integrasi-end-to-end)
7. [Troubleshooting](#7-troubleshooting)
8. [Batasan saat ini](#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](#4-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

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

```bash
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**:

```json
{
  "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-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}`

```bash
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.

```bash
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):

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

```json
{"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):

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.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`:

```json
{
  "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`:

```json
{
  "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 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.

#### Node.js

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

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

```json
{
  "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:

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`.

```bash
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):

```bash
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_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.
