# StreadPay API

Terima pembayaran QRIS dari aplikasi, website, atau bot kamu lewat satu REST API.

- **Base URL:** `https://streadpay.com/v1`
- **Format:** JSON, UTF-8
- **Nominal:** rupiah, bilangan bulat (tanpa desimal). `10000` = Rp10.000
- **Waktu:** ISO 8601 UTC, contoh `2026-10-31T15:30:00Z`
- **Versi Markdown halaman ini:** `https://streadpay.com/docs.md` (cocok untuk dibaca AI)

---

## Mulai cepat

**1. Ambil API key.** Daftar, buka Dashboard → API keys → Buat key. Mulai dengan key test (`str_test_...`).

**2. Buat tagihan QRIS.**

```bash
curl -X POST https://streadpay.com/v1/payments \
  -H "Authorization: Bearer str_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"amount": 10000, "merchant_ref": "ORDER-1029"}'
```

**3. Tampilkan QR ke pembeli** dari `qr_string` atau kirim `pay_url`. Saat pembeli selesai bayar, server kamu menerima webhook `payment.paid`. Tandai pesanan lunas di sana.

---

## Autentikasi

Kirim API key di header `Authorization` pada setiap request.

```http
Authorization: Bearer str_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

| Prefix | Mode | Uang sungguhan |
|---|---|---|
| `str_test_` | Test. Pembayaran disimulasikan dari dashboard atau `POST /v1/payments/{id}/simulate` | Tidak |
| `str_live_` | Live. QRIS asli | Ya |

Simpan key hanya di server (environment variable). Jangan taruh di kode frontend, aplikasi mobile, atau repository publik.

---

## Konsep penting

### Biaya dan siapa yang menanggung

Biaya layanan per transaksi sukses: **Rp100 + 0,7%** dari `amount`. Setiap tagihan juga mendapat **kode unik** acak Rp1–99 agar tidak ada dua tagihan dengan nominal sama.

Atur siapa yang menanggung biaya di Dashboard → Pengaturan, atau per transaksi dengan `fee_bearer`.

| `fee_bearer` | Pembeli bayar (`total_amount`) | Masuk saldo kamu (`net_amount`) |
|---|---|---|
| `customer` (default) | amount + fee + unique_code | amount |
| `merchant` | amount + unique_code | amount − fee |

Contoh `amount` 10000, kode unik 32: `customer` → pembeli bayar **10202**, saldo +10000. `merchant` → pembeli bayar **10032**, saldo +9830.

### Status pembayaran

| Status | Arti | Final |
|---|---|---|
| `PENDING` | Menunggu pembeli scan dan bayar | Tidak |
| `PAID` | Sudah dibayar. Kirim barang / aktifkan layanan | Ya |
| `EXPIRED` | Lewat batas waktu, tidak dibayar | Ya |
| `CANCELLED` | Dibatalkan lewat API | Ya |
| `FAILED` | Gagal diproses penyedia pembayaran | Ya |

Anggap pembayaran lunas **hanya** jika status `PAID` dari webhook atau dari `GET /v1/payments/{id}`. Jangan percaya redirect atau laporan pembeli.

### Mencegah tagihan dobel

Kirim header `Idempotency-Key` (string unik, maks 64 karakter) pada request `POST`. Request ulang dengan key yang sama dalam 24 jam mengembalikan respons pertama, bukan membuat tagihan baru. Cocok untuk bot yang bisa mengirim ulang saat koneksi putus.

```http
Idempotency-Key: ORDER-1029
```

---

## Format respons

Semua respons memakai bentuk yang sama.

```json
{ "success": true, "data": { } }
```

```json
{ "success": false, "error": { "code": "MIN_AMOUNT", "message": "Minimal transaksi Rp1.000." } }
```

Daftar berhalaman menambahkan `pagination`:

```json
{
  "success": true,
  "data": [ ],
  "pagination": { "page": 1, "limit": 20, "total": 158, "pages": 8 }
}
```

---

## Endpoint

| Method | Path | Fungsi |
|---|---|---|
| POST | `/v1/payments` | Buat tagihan QRIS |
| GET | `/v1/payments/{id}` | Cek detail & status tagihan |
| POST | `/v1/payments/{id}/cancel` | Batalkan tagihan `PENDING` |
| GET | `/v1/transactions` | Daftar tagihan |
| GET | `/v1/balance` | Saldo |
| GET | `/v1/mutations` | Riwayat mutasi saldo |
| POST | `/v1/withdrawals` | Ajukan pencairan |
| GET | `/v1/withdrawals/{id}` | Status pencairan |

### Buat pembayaran

`POST /v1/payments`

| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
| `amount` | integer | Ya | Harga produk dalam rupiah. Minimal 1000 |
| `merchant_ref` | string | Tidak | ID pesanan di sistem kamu, maks 64 karakter. Dikirim balik di webhook |
| `fee_bearer` | string | Tidak | `customer` atau `merchant`. Default dari Pengaturan akun |
| `expires_in` | integer | Tidak | Batas bayar dalam menit, 5–1440. Default 30 |
| `description` | string | Tidak | Keterangan untuk pembeli, maks 255 karakter |
| `metadata` | object | Tidak | Data bebas (maks 2 KB), dikembalikan apa adanya di webhook |

Request:

```json
{
  "amount": 10000,
  "merchant_ref": "ORDER-1029",
  "description": "Topup 100 diamond",
  "metadata": { "telegram_user_id": 123456789 }
}
```

Respons `201 Created`:

```json
{
  "success": true,
  "data": {
    "id": "pay_8fKq2LmZx01aB9cD",
    "invoice_id": "INV-20261031-7KQ2XM",
    "merchant_ref": "ORDER-1029",
    "status": "PENDING",
    "amount": 10000,
    "fee": 170,
    "unique_code": 32,
    "total_amount": 10202,
    "net_amount": 10000,
    "fee_bearer": "customer",
    "description": "Topup 100 diamond",
    "qr_string": "00020101021226670016COM.NOBUBANK.WWW...",
    "qr_image_url": "https://streadpay.com/pay/pay_8fKq2LmZx01aB9cD/qr.png",
    "pay_url": "https://streadpay.com/pay/pay_8fKq2LmZx01aB9cD",
    "expires_at": "2026-10-31T15:30:00Z",
    "paid_at": null,
    "created_at": "2026-10-31T15:00:00Z",
    "metadata": { "telegram_user_id": 123456789 }
  }
}
```

**Objek payment**

| Field | Tipe | Keterangan |
|---|---|---|
| `id` | string | ID pembayaran, diawali `pay_` |
| `invoice_id` | string | Nomor invoice StreadPay |
| `merchant_ref` | string \| null | ID pesanan dari kamu |
| `status` | string | Lihat tabel status |
| `amount` | integer | Harga produk |
| `fee` | integer | Biaya layanan |
| `unique_code` | integer | Kode unik 1–99 |
| `total_amount` | integer | **Nominal yang harus dibayar pembeli** |
| `net_amount` | integer | Nominal yang masuk ke saldo saat `PAID` |
| `fee_bearer` | string | `customer` atau `merchant` |
| `qr_string` | string | Isi QRIS. Ubah jadi gambar QR dengan library apa pun |
| `qr_image_url` | string | Gambar QR (PNG) siap kirim, misalnya ke chat bot |
| `pay_url` | string | Halaman bayar StreadPay berisi QR dan status otomatis |
| `expires_at` | string | Batas waktu bayar |
| `paid_at` | string \| null | Waktu dibayar |
| `created_at` | string | Waktu dibuat |
| `metadata` | object \| null | Data dari kamu |

### Cek pembayaran

`GET /v1/payments/{id}`

`{id}` boleh berupa `id` (`pay_...`) atau `invoice_id`. Respons: objek payment.

```bash
curl https://streadpay.com/v1/payments/pay_8fKq2LmZx01aB9cD \
  -H "Authorization: Bearer str_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

Pakai untuk cek ulang status jika webhook terlambat. Jangan polling lebih sering dari sekali per 5 detik.

### Batalkan pembayaran

`POST /v1/payments/{id}/cancel`

Hanya untuk status `PENDING`. Respons: objek payment dengan status `CANCELLED`. Jika sudah `PAID` atau final, error `INVALID_STATE`.

### Simulasi bayar (key test saja)

`POST /v1/payments/{id}/simulate`

Menandai tagihan mode test sebagai `PAID` dan mengirim webhook `payment.paid` dengan `mode: "test"`, untuk menguji bot tanpa uang sungguhan. Dengan key live: error `TEST_ONLY`. Pembeli di halaman bayar tidak bisa mensimulasikan; hanya kamu dari dashboard atau API ini.

### Daftar transaksi

`GET /v1/transactions`

| Query | Keterangan |
|---|---|
| `status` | Filter status, contoh `PAID` |
| `merchant_ref` | Cari berdasarkan ID pesanan kamu |
| `from`, `to` | Rentang tanggal `YYYY-MM-DD` (WIB) |
| `page` | Halaman, mulai 1 |
| `limit` | Jumlah per halaman, 1–100. Default 20 |

Respons: array objek payment + `pagination`, urut terbaru.

### Saldo

`GET /v1/balance`

```json
{
  "success": true,
  "data": { "available": 2440256, "pending": 98000, "reserved": 0 }
}
```

| Field | Arti |
|---|---|
| `available` | Bisa dicairkan sekarang |
| `pending` | Dari pembayaran terbaru, tersedia H+1 |
| `reserved` | Sedang dalam proses pencairan |

### Mutasi saldo

`GET /v1/mutations` dengan query `page`, `limit`, `from`, `to`.

```json
{
  "id": 1042,
  "type": "payment_credit",
  "amount": 10000,
  "balance_after": 2440256,
  "reference": "pay_8fKq2LmZx01aB9cD",
  "description": "Pembayaran QRIS INV-20261031-7KQ2XM",
  "created_at": "2026-10-31T15:02:11Z"
}
```

`type`: `payment_credit` (pembayaran masuk), `settlement` (pending → tersedia), `withdrawal_debit` (pencairan diajukan, biaya sudah termasuk), `withdrawal_refund` (pencairan ditolak, saldo kembali), `adjustment` (koreksi oleh admin). `amount` negatif untuk uang keluar.

### Ajukan pencairan

`POST /v1/withdrawals`

| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
| `amount` | integer | Ya | Minimal 10000, tidak melebihi `available` |
| `bank_account_id` | string | Tidak | Default rekening utama di dashboard |

Biaya pencairan Rp2.500 dipotong dari nominal: cair 100000 → diterima rekening 97500.

```json
{
  "success": true,
  "data": {
    "id": "wd_Qm81ZpXc0LkT4rAa",
    "status": "REQUESTED",
    "amount": 100000,
    "fee": 2500,
    "net": 97500,
    "bank": { "bank_code": "BCA", "account_no": "******7890", "account_name": "Budi Santoso" },
    "created_at": "2026-10-31T15:10:00Z"
  }
}
```

Status pencairan: `REQUESTED` → `PROCESSING` → `SUCCESS`, atau `REJECTED` / `FAILED` (saldo dikembalikan utuh).

### Status pencairan

`GET /v1/withdrawals/{id}`. Respons: objek pencairan seperti di atas.

---

## Webhook

StreadPay mengirim `POST` JSON ke URL yang kamu daftarkan di Dashboard → Webhooks. Kamu bisa mendaftarkan beberapa URL; masing-masing punya signing secret sendiri (`whsec_...`) dan pilihan event sendiri.

### Event

| Event | Kapan |
|---|---|
| `payment.paid` | Pembayaran lunas. **Event terpenting** |
| `payment.expired` | Tagihan kedaluwarsa |
| `payment.cancelled` | Tagihan dibatalkan |
| `settlement.available` | Dana pindah dari `pending` ke `available` |
| `withdrawal.success` | Pencairan berhasil ditransfer |
| `withdrawal.failed` | Pencairan gagal atau ditolak, saldo dikembalikan |

### Header

| Header | Isi |
|---|---|
| `X-StreadPay-Event` | Nama event, contoh `payment.paid` |
| `X-StreadPay-Id` | ID event unik, diawali `evt_`. Sama di setiap percobaan ulang |
| `X-StreadPay-Timestamp` | Unix timestamp (detik) saat dikirim |
| `X-StreadPay-Signature` | `sha256=` + HMAC-SHA256 hex |

### Payload

```json
{
  "id": "evt_Zt4kP0mQ2wX8nB1c",
  "event": "payment.paid",
  "mode": "live",
  "created_at": "2026-10-31T15:02:11Z",
  "data": {
    "id": "pay_8fKq2LmZx01aB9cD",
    "invoice_id": "INV-20261031-7KQ2XM",
    "merchant_ref": "ORDER-1029",
    "status": "PAID",
    "amount": 10000,
    "total_amount": 10202,
    "net_amount": 10000,
    "paid_at": "2026-10-31T15:02:09Z",
    "metadata": { "telegram_user_id": 123456789 }
  }
}
```

`data` pada event `payment.*` adalah objek payment lengkap. `mode` berisi `test` atau `live`; abaikan event `test` di server produksi.

Tombol **Kirim tes** di Dashboard → Webhooks mengirim contoh `payment.paid` dengan `"mode": "test"` dan `"data.test": true`.

### Verifikasi signature

Wajib, agar orang lain tidak bisa memalsukan notifikasi lunas.

1. Ambil **raw body** persis seperti diterima (jangan parse lalu stringify ulang).
2. Hitung `HMAC_SHA256(secret, timestamp + "." + rawBody)` dalam hex.
3. Bandingkan dengan nilai setelah `sha256=` memakai perbandingan constant-time.
4. Tolak jika selisih timestamp dengan jam server lebih dari 5 menit.

### Aturan menerima webhook

- Balas HTTP `2xx` dalam 10 detik. Proses berat (kirim pesan, update stok) lakukan setelah membalas atau di antrean.
- Gagal atau timeout dikirim ulang: 1 menit, 5 menit, 30 menit, 2 jam, 6 jam (maks 5 kali).
- Webhook bisa datang lebih dari sekali. Simpan `X-StreadPay-Id` dan abaikan yang sudah diproses.
- Cocokkan pesanan memakai `merchant_ref` dan pastikan `amount` sama dengan pesananmu.
- Di server produksi, **abaikan event dengan `mode: "test"`**. Kirim produk hanya untuk `payment.paid` dengan `mode: "live"`.

---

## Contoh integrasi

Semua contoh memakai environment variable `STREADPAY_API_KEY` dan `STREADPAY_WEBHOOK_SECRET`.

### Node.js

```js
// npm install express
const express = require('express');
const crypto = require('crypto');

const API = 'https://streadpay.com/v1';
const KEY = process.env.STREADPAY_API_KEY;
const SECRET = process.env.STREADPAY_WEBHOOK_SECRET;

// Buat tagihan. Panggil saat pembeli checkout / mengetik perintah beli di bot.
async function createPayment(orderId, amount, metadata = {}) {
  const res = await fetch(`${API}/payments`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': orderId,
    },
    body: JSON.stringify({ amount, merchant_ref: orderId, metadata }),
  });
  const json = await res.json();
  if (!json.success) throw new Error(`${json.error.code}: ${json.error.message}`);
  return json.data; // kirim data.qr_image_url atau data.pay_url ke pembeli
}

const app = express();

// Webhook: pakai express.raw agar signature dihitung dari body asli.
app.post('/webhook/streadpay', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-StreadPay-Timestamp');
  const signature = (req.get('X-StreadPay-Signature') || '').replace('sha256=', '');
  const expected = crypto.createHmac('sha256', SECRET).update(`${timestamp}.${req.body}`).digest('hex');

  const valid = signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  if (!valid || !fresh) return res.status(401).end();

  res.sendStatus(200); // balas dulu, proses setelahnya

  const event = JSON.parse(req.body);
  if (event.event === 'payment.paid') {
    // TODO: cek event.id belum pernah diproses, lalu tandai pesanan lunas
    console.log('Lunas:', event.data.merchant_ref, event.data.amount);
  }
});

app.listen(3000);
```

### PHP

```php
<?php
// Buat tagihan
function createPayment(string $orderId, int $amount): array {
    $ch = curl_init('https://streadpay.com/v1/payments');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('STREADPAY_API_KEY'),
            'Content-Type: application/json',
            'Idempotency-Key: ' . $orderId,
        ],
        CURLOPT_POSTFIELDS => json_encode(['amount' => $amount, 'merchant_ref' => $orderId]),
    ]);
    $json = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (!$json['success']) throw new Exception($json['error']['code']);
    return $json['data'];
}

// webhook.php
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_STREADPAY_TIMESTAMP'] ?? '';
$signature = str_replace('sha256=', '', $_SERVER['HTTP_X_STREADPAY_SIGNATURE'] ?? '');
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, getenv('STREADPAY_WEBHOOK_SECRET'));

if (!hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}
http_response_code(200);

$event = json_decode($raw, true);
if ($event['event'] === 'payment.paid') {
    // tandai pesanan $event['data']['merchant_ref'] lunas
}
```

### Python

```python
# pip install requests flask
import hashlib, hmac, os, time
import requests
from flask import Flask, request, abort

API = "https://streadpay.com/v1"
KEY = os.environ["STREADPAY_API_KEY"]
SECRET = os.environ["STREADPAY_WEBHOOK_SECRET"]

def create_payment(order_id: str, amount: int) -> dict:
    r = requests.post(
        f"{API}/payments",
        headers={"Authorization": f"Bearer {KEY}", "Idempotency-Key": order_id},
        json={"amount": amount, "merchant_ref": order_id},
        timeout=15,
    )
    body = r.json()
    if not body["success"]:
        raise RuntimeError(body["error"]["code"])
    return body["data"]  # kirim body["data"]["qr_image_url"] ke pembeli

app = Flask(__name__)

@app.post("/webhook/streadpay")
def webhook():
    raw = request.get_data()
    ts = request.headers.get("X-StreadPay-Timestamp", "")
    sig = request.headers.get("X-StreadPay-Signature", "").replace("sha256=", "")
    expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected) or abs(time.time() - int(ts or 0)) > 300:
        abort(401)
    event = request.get_json()
    if event["event"] == "payment.paid":
        pass  # tandai pesanan event["data"]["merchant_ref"] lunas
    return "", 200
```

### Alur untuk bot (Telegram, WhatsApp, Discord)

1. Pembeli memilih produk → bot memanggil `POST /v1/payments` dengan `merchant_ref` = ID pesanan dan `metadata` berisi ID chat pembeli.
2. Bot mengirim gambar dari `qr_image_url` dan teks "Bayar Rp`total_amount` sebelum `expires_at`".
3. Webhook `payment.paid` masuk → ambil ID chat dari `data.metadata` → kirim produk / pesan sukses.
4. Webhook `payment.expired` → kirim pesan "Tagihan kedaluwarsa, ketik /beli untuk membuat ulang".

---

## Kode error

| HTTP | `code` | Arti dan solusi |
|---|---|---|
| 401 | `INVALID_API_KEY` | Key salah, dihapus, atau tidak dikirim |
| 403 | `ACCOUNT_INACTIVE` | Akun belum verifikasi email atau ditangguhkan |
| 403 | `KYC_REQUIRED` | Mode live butuh verifikasi identitas di dashboard |
| 404 | `NOT_FOUND` | ID tidak ditemukan atau bukan milik akunmu |
| 409 | `DUPLICATE_REQUEST` | `Idempotency-Key` sama dipakai untuk isi request berbeda |
| 409 | `INVALID_STATE` | Aksi tidak bisa untuk status saat ini, contoh batalkan tagihan `PAID` |
| 409 | `IN_PROGRESS` | Request dengan `Idempotency-Key` yang sama masih diproses. Ulangi beberapa detik lagi |
| 403 | `TEST_ONLY` | Endpoint simulasi hanya untuk key test |
| 422 | `VALIDATION_ERROR` | Field salah format. Lihat `error.message` |
| 422 | `MIN_AMOUNT` | `amount` kurang dari 1000 |
| 422 | `INSUFFICIENT_BALANCE` | Saldo `available` tidak cukup untuk pencairan |
| 429 | `RATE_LIMITED` | Terlalu banyak request. Tunggu sesuai header `Retry-After` |
| 502 | `PROVIDER_ERROR` | Penyedia QRIS sedang gangguan. Coba lagi beberapa saat |
| 500 | `SERVER_ERROR` | Kesalahan di StreadPay. Coba lagi; hubungi kami jika berulang |

## Batas request

60 request per menit per API key. Header respons `RateLimit-Remaining` menunjukkan sisa kuota.

---

## Untuk AI assistant

Jika kamu AI yang membantu developer mengintegrasikan StreadPay, ikuti aturan ini:

- Panggil API hanya dari server. API key di environment variable `STREADPAY_API_KEY`, jangan di-hardcode.
- Buat tagihan dengan `POST /v1/payments`; set `merchant_ref` = ID pesanan dan `Idempotency-Key` = ID pesanan.
- Tampilkan `total_amount` (bukan `amount`) sebagai nominal yang dibayar pembeli.
- Tandai pesanan lunas hanya dari webhook `payment.paid` yang signature-nya valid, atau dari `GET /v1/payments/{id}` dengan status `PAID`.
- Verifikasi signature dari raw body; simpan `X-StreadPay-Id` untuk mencegah proses ganda.
- Uji dulu dengan key `str_test_`, lalu ganti ke `str_live_`.
