# PinnPay Shopee Gateway — Dokumentasi API

> Sumber tunggal dokumentasi. Halaman web `https://pinnduit.my.id/docs` dirender dari berkas ini.
> Versi mesin-terbaca: [`https://pinnduit.my.id/openapi.json`](openapi.json) · ringkasan untuk AI: [`https://pinnduit.my.id/llms.txt`](llms.txt)

## 1. Ringkasan (baca ini dulu)

PinnPay Shopee Gateway membuat **QRIS dinamis** untuk sebuah nominal, lalu mendeteksi pembayarannya dari mutasi
ShopeePay Merchant dan memberi tahu sistem Anda lewat **webhook** (atau polling status).

| Item | Nilai |
|---|---|
| Base URL | `https://pinnduit.my.id` |
| Format | JSON (UTF-8), `Content-Type: application/json` |
| Zona waktu semua timestamp | WIB (UTC+7), format `YYYY-MM-DD HH:MM:SS` |
| Mata uang | Rupiah, bilangan bulat tanpa desimal |
| Autentikasi | `client_id` + `apikey` milik client (dibuat di panel admin, tab Clients) |
| Masa berlaku transaksi | 5 menit (`expired_at`) |
| Masa tenggang pemulihan | pembayaran yang masuk sampai 30 menit setelah expire tetap dilunasi |
| Status transaksi | `PENDING` → `PAID` atau `EXPIRED` (`EXPIRED` bisa menjadi `PAID` selama masa tenggang) |

**Alur integrasi minimum:**

1. Server Anda memanggil `POST /api/pay` dengan `client_id`, `apikey`, `nominal`, `order_ref`.
2. Simpan `tx_id`. Gambar QR dari `qris_payload` dan tampilkan ke pelanggan beserta **`nominal` dari respons**.
3. Tunggu webhook `PAID` di `webhook_url` client, dan/atau polling `GET /api/status/{tx_id}`.
4. Saat `PAID` (terverifikasi), tandai order lunas. Lakukan sekali per `tx_id` (idempoten).

## 2. Aturan penting

1. **Tampilkan `nominal` dari respons, bukan nominal yang Anda kirim.** Bila ada transaksi aktif lain dengan nominal
   sama, gateway menambah Rp 1–99 agar nominal unik (`unique_add`). Pelanggan harus membayar nominal persis itu,
   karena pencocokan pembayaran dilakukan berdasarkan nominal.
2. **`apikey` hanya boleh ada di server Anda.** Jangan taruh di JavaScript browser, aplikasi mobile, atau repositori.
3. **Verifikasi tanda tangan webhook** sebelum mempercayai isinya (bagian 5).
4. **Webhook bisa datang lebih dari sekali.** Jadikan `tx_id` kunci idempotensi.
5. Jangan menganggap order lunas hanya karena pelanggan menekan "sudah bayar". Satu-satunya bukti adalah
   status `PAID` (webhook terverifikasi atau `GET /api/status`).
6. `qris_payload` adalah teks QRIS. Ubah menjadi gambar di sisi Anda (bagian 6), jangan kirim ke layanan pihak ketiga.

## 3. Endpoint

### 3.1 `POST /api/pay` — buat transaksi

Request body (JSON):

| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
| `client_id` | string | ya | ID client |
| `apikey` | string | ya | API key client |
| `nominal` | integer | ya | Rupiah, `1` s/d `50000000` |
| `order_ref` | string | tidak | Referensi order Anda, maks 64 karakter (alias `order_id`). Dikembalikan di webhook |

Contoh:

```bash
curl -X POST https://pinnduit.my.id/api/pay \
  -H "Content-Type: application/json" \
  -d '{"client_id":"toko_a","apikey":"API_KEY_CLIENT","nominal":50000,"order_ref":"INV-123"}'
```

Respons `200`:

```json
{
  "status": "PENDING",
  "message": "Transaksi Dibuat",
  "tx_id": 42,
  "client_id": "toko_a",
  "order_ref": "INV-123",
  "nominal": 50001,
  "base_nominal": 50000,
  "unique_add": 1,
  "qris_payload": "00020101021226...6304ABCD",
  "created_at": "2026-11-15 00:35:36",
  "expired_at": "2026-11-15 00:40:36",
  "paid_at": null,
  "rrn": null
}
```

| Field respons | Keterangan |
|---|---|
| `tx_id` | ID transaksi. Simpan; dipakai untuk status dan sebagai kunci idempotensi |
| `nominal` | **Jumlah yang harus dibayar pelanggan** (= `base_nominal` + `unique_add`) |
| `base_nominal` | Nominal yang Anda minta |
| `unique_add` | Tambahan rupiah (0–99) agar nominal unik |
| `qris_payload` | String QRIS dinamis untuk dijadikan QR |
| `expired_at` | Batas bayar normal (WIB) |
| `status` | `PENDING` saat dibuat |

Error:

| HTTP | Body `error` | Arti / tindakan |
|---|---|---|
| 400 | `Payload tidak lengkap. Wajib: apikey, client_id, nominal` | Field wajib kosong |
| 400 | `Nominal tidak valid` | Bukan integer, ≤ 0, atau > 50.000.000 |
| 401 | `Kredensial tidak valid` | `client_id`/`apikey` salah atau client nonaktif |
| 503 | `Terlalu banyak transaksi aktif dengan nominal ini...` | Coba lagi sesaat atau ubah nominal |
| 500 | pesan konfigurasi (mis. QRIS) | Masalah di server gateway; hubungi admin |

### 3.2 `GET /api/status/{tx_id}` — cek status

Header wajib: `X-Client-Id`, `X-Api-Key`. Hanya transaksi milik client tersebut yang terlihat.

```bash
curl https://pinnduit.my.id/api/status/42 -H "X-Client-Id: toko_a" -H "X-Api-Key: API_KEY_CLIENT"
```

Respons `200` (field sama seperti di atas, tanpa `qris_payload`):

```json
{
  "tx_id": 42, "client_id": "toko_a", "order_ref": "INV-123",
  "status": "PAID", "nominal": 50001, "base_nominal": 50000,
  "created_at": "2026-11-15 00:35:36", "expired_at": "2026-11-15 00:40:36",
  "paid_at": "2026-11-15 00:36:09", "rrn": "ATbkFV3DPbFAL"
}
```

`404` `{"error":"Transaksi tidak ditemukan"}` bila `tx_id` tidak ada atau bukan milik client.
Polling: gunakan jeda ≥ 3 detik; webhook adalah jalur utama, polling hanya cadangan.

### 3.3 `GET /health` — cek layanan

```json
{"status":"online","service":"PinnPay Shopee Gateway","wib_time":"2026-11-15 00:57:06","session_valid":true}
```

`session_valid: false` berarti sesi Shopee gateway mati; transaksi tetap bisa dibuat tapi pembayaran belum bisa
dicocokkan sampai sesi pulih.

## 4. Siklus status

| Status | Arti | Berikutnya |
|---|---|---|
| `PENDING` | Menunggu pembayaran, belum lewat `expired_at` | `PAID` atau `EXPIRED` |
| `EXPIRED` | Lewat 5 menit tanpa pembayaran terdeteksi | Bisa menjadi `PAID` bila pembayaran masuk dalam masa tenggang 30 menit |
| `PAID` | Pembayaran terdeteksi dan dicocokkan | Final |

Setelah masa tenggang habis, `EXPIRED` bersifat final. Buat transaksi baru untuk order yang sama bila perlu.

## 5. Webhook

Saat transaksi menjadi `PAID`, gateway melakukan `POST` ke `webhook_url` client (diatur di panel, tab Clients).

Header:

```
Content-Type: application/json
X-PinnPay-Signature: <hex HMAC-SHA256 dari RAW body, kunci = apikey client>
```

Body:

```json
{
  "status": "PAID",
  "client_id": "toko_a",
  "tx_id": 42,
  "order_ref": "INV-123",
  "nominal": 50001,
  "base_nominal": 50000,
  "rrn": "ATbkFV3DPbFAL",
  "provider_txid": "154362095251497703",
  "sender": "Bank Jago",
  "time_paid": "2026-11-15 00:36:09"
}
```

Aturan penerima:

- Balas **HTTP 2xx** secepatnya untuk menandai terkirim. Selain 2xx dianggap gagal dan dicoba ulang.
- Pengiriman ulang: percobaan pada 0 d, 10 d, 1 mnt, 5 mnt; disapu ulang tiap menit; maksimum 12 percobaan, selama
  transaksi `PAID` berusia < 24 jam. Status pengiriman terlihat di panel (tab Transaksi) dan bisa dikirim ulang manual.
- Hitung HMAC dari **raw body persis seperti diterima** (jangan parse lalu serialisasi ulang), bandingkan dengan
  perbandingan waktu-konstan.
- Idempoten: abaikan webhook dengan `tx_id` yang sudah diproses.

Verifikasi — Node.js:

```js
const crypto = require('crypto');
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
  const sig = crypto.createHmac('sha256', API_KEY).update(req.body).digest('hex');
  const got = req.get('X-PinnPay-Signature') || '';
  const ok = got.length === sig.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(got));
  if (!ok) return res.sendStatus(401);
  const d = JSON.parse(req.body);          // gunakan d.tx_id sebagai kunci idempotensi
  res.sendStatus(200);
});
```

Verifikasi — PHP:

```php
<?php
$raw = file_get_contents('php://input');
$sig = hash_hmac('sha256', $raw, $API_KEY);
if (!hash_equals($sig, $_SERVER['HTTP_X_PINNPAY_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
$d = json_decode($raw, true);              // gunakan $d['tx_id'] sebagai kunci idempotensi
http_response_code(200);
```

Verifikasi — Python:

```python
import hmac, hashlib
def valid(raw: bytes, header: str, key: str) -> bool:
    sig = hmac.new(key.encode(), raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, header or "")
```

## 6. Menampilkan QR dari `qris_payload`

Browser (pustaka `qrcodejs`, offline di sisi klien):

```html
<div id="qr"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>new QRCode(document.getElementById('qr'), { text: data.qris_payload, width: 260, height: 260 });</script>
```

Python:

```python
import qrcode
qrcode.make(data["qris_payload"]).save("qris.png")
```

Tampilkan juga: nominal (`data.nominal`), batas bayar (`expired_at`), dan petunjuk "bayar nominal persis ini".

## 7. Contoh lengkap (server → gateway)

Python:

```python
import requests
BASE, CID, KEY = "https://pinnduit.my.id", "toko_a", "API_KEY_CLIENT"
r = requests.post(f"{BASE}/api/pay", json={"client_id": CID, "apikey": KEY, "nominal": 50000, "order_ref": "INV-123"}, timeout=15)
r.raise_for_status()
tx = r.json()
print(tx["tx_id"], tx["nominal"], tx["expired_at"])          # tampilkan tx["nominal"] ke pelanggan
s = requests.get(f"{BASE}/api/status/{tx['tx_id']}", headers={"X-Client-Id": CID, "X-Api-Key": KEY}, timeout=15).json()
```

Node.js:

```js
const r = await fetch(`${BASE}/api/pay`, {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ client_id: CID, apikey: KEY, nominal: 50000, order_ref: 'INV-123' }),
});
if (!r.ok) throw new Error((await r.json()).error);
const tx = await r.json();
```

PHP:

```php
<?php
$ch = curl_init("$BASE/api/pay");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15,
  CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
  CURLOPT_POSTFIELDS => json_encode(['client_id'=>$CID,'apikey'=>$KEY,'nominal'=>50000,'order_ref'=>'INV-123'])]);
$tx = json_decode(curl_exec($ch), true);
```

## 8. Panduan untuk AI agent

Bila Anda adalah agen yang mengintegrasikan API ini ke aplikasi pengguna:

- **Jangan menebak kredensial.** Minta `client_id`, `apikey`, dan `webhook_url` dari pengguna; simpan di variabel
  lingkungan server, bukan di kode klien atau repositori.
- Nominal ke pelanggan = `response.nominal`. Jangan hitung sendiri.
- Terapkan verifikasi HMAC pada raw body dan idempotensi `tx_id` **sebelum** mengubah status order.
- Jangan polling lebih cepat dari 3 detik; jangan membuat transaksi baru berulang untuk order yang sama selama
  transaksi sebelumnya masih `PENDING`.
- Anggap `EXPIRED` belum final selama < 30 menit sejak `expired_at`; bila pelanggan mengaku sudah bayar, cek
  `GET /api/status/{tx_id}` lagi sebelum membuat transaksi baru.
- Kegagalan jaringan saat `POST /api/pay`: jangan ulangi buta-buta (bisa menghasilkan transaksi ganda). Gunakan
  `order_ref` unik dan periksa status sebelum mengulang.
- Endpoint `/admin/*` dan halaman panel bersifat internal untuk administrator; jangan dipakai dari aplikasi pelanggan.

## 9. Referensi cepat

| Method | Path | Auth | Fungsi |
|---|---|---|---|
| POST | `/api/pay` | `client_id`+`apikey` di body | Buat transaksi + QRIS dinamis |
| GET | `/api/status/{tx_id}` | header `X-Client-Id`, `X-Api-Key` | Status transaksi |
| GET | `/health` | — | Cek layanan |
| GET | `/docs`, `/public/docs` | — | Dokumentasi ini (HTML) |
| GET | `/docs.md`, `/public/docs/index.md` | — | Dokumentasi ini (Markdown) |
| GET | `/openapi.json` | — | Spesifikasi OpenAPI 3.1 |
| GET | `/llms.txt` | — | Indeks ringkas untuk agen AI |
