openapi: 3.0.3 info: title: Sarana — Client / Developer API version: 1.0.0 description: | API integrasi untuk **client/developer** (merchant). Berbeda dari API internal/admin — di sini hanya endpoint yang relevan untuk integrasi pembayaran. ## Getting Started (cara pakai) 1. **Dapatkan API key.** Admin Sarana membuatkan API key untuk akun kamu (hanya untuk client dengan akses developer aktif). Formatnya `sk_live_xxxxx`. 2. **Kirim API key di setiap request** lewat header: ``` X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` 3. **Cek fee dulu** (opsional) via `POST /fees/calculate` untuk tahu biaya & jumlah bersih. 4. **Buat pembayaran** — pilih channel: Virtual Account (`/payments/va`), e-wallet (`/payments/ewallet`), QRIS (`/payments/qris`), atau invoice (`/payments/invoice`). 5. **Terima notifikasi** lewat **callback/webhook** yang kamu daftarkan — Sarana akan mem-forward status pembayaran ke URL kamu (lihat tag *Callbacks*). 6. **Cek status** kapan saja via `GET /payments/{id}/status` atau lookup `by external_id`. 7. **Tarik dana** (settlement/withdrawal) ke rekening kamu via `/client/settlements` atau `/withdrawals`. ## Format response ```json { "success": true, "message": "...", "code": 200, "data": { } } ``` servers: - url: http://localhost:8080/api/v2 description: Local Development - url: https://api.internal-go.saranatechnology.com/api/v2 description: Production security: - ApiKeyAuth: [] tags: - name: Payments description: Buat pembayaran (VA, e-wallet, QRIS, invoice) & cek status - name: Fees description: Hitung biaya & jumlah bersih sebelum transaksi - name: Transactions description: Catat & lihat transaksi - name: Withdrawals description: Penarikan dana ke rekening - name: Settlements description: Settlement/pencairan untuk client - name: Bank Channels description: Daftar channel/bank yang tersedia - name: Account description: Saldo & info akun client - name: Callbacks description: Format webhook yang dikirim Sarana ke URL kamu (referensi) - name: Auth (Portal) description: Registrasi & login akun klien untuk portal (client token) - name: API Keys (Portal) description: Kelola API key sendiri (khusus klien has_developer) components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-API-Key description: API key client (format `sk_live_...`). Minta ke admin Sarana. ClientToken: type: http scheme: bearer description: Token sesi klien dari `POST /client/auth/login` (untuk endpoint portal). schemas: ApiResponse: type: object properties: success: { type: boolean } message: { type: string } code: { type: integer } data: { type: object } Account: type: object description: Subset aman profil client (tanpa secret/token/hash). properties: id: { type: integer, example: 14 } nama: { type: string, example: "Toko XYZ" } email: { type: string, example: "owner@toko-xyz.com" } kode: { type: string, example: "CLT-1720000000000" } pic: { type: string, example: "Budi" } no_telp_pic: { type: string, example: "08123456789" } email_pic: { type: string, example: "budi@toko-xyz.com" } alamat: { type: string, example: "Jl. Merdeka 1" } norek: { type: string, example: "1234567890" } bank: { type: string, example: "BCA" } status: { type: string, example: "approved" } has_developer: { type: boolean, example: true } is_auto_approve_withdrawal: { type: boolean, example: false } saldo: { type: number, example: 226687500 } saldo_yang_bisa_ditarik: { type: number, example: 200000000 } AccountEnvelope: type: object properties: success: { type: boolean, example: true } message: { type: string, example: "Account retrieved" } code: { type: integer, example: 200 } data: { $ref: '#/components/schemas/Account' } AccountUpdate: type: object description: Semua field opsional; hanya field ini yang boleh diubah client. properties: nama: { type: string } email: { type: string } alamat: { type: string } pic: { type: string } no_telp_pic: { type: string } email_pic: { type: string } norek: { type: string, description: "Numeric" } bank: { type: string, description: "Kode/nama bank; lihat /bank-channels/active" } paths: # ===================== AUTH PORTAL (rencana — lihat issue #2) ===================== /auth/register: post: summary: Registrasi akun klien (portal) tags: [Auth (Portal)] security: [] description: | Self-signup klien. Akun dibuat **pending** (`has_developer=false`) dan butuh approval admin. Klien tetap bisa login & melihat statusnya, tetapi semua aksi developer dikunci (403) sampai admin mengaktifkan akses developer. requestBody: required: true content: application/json: schema: type: object required: [nama, email, password] properties: nama: { type: string } email: { type: string } password: { type: string, format: password } responses: '201': { description: Akun dibuat } '409': { description: Email sudah terdaftar } /auth/login: post: summary: Login klien (portal) tags: [Auth (Portal)] security: [] description: | Setiap klien dengan kredensial valid bisa login, termasuk akun **pending** (`has_developer=false`). Akun pending dapat melihat statusnya via `/auth/me`, tetapi setiap endpoint aksi mengembalikan 403 sampai admin menyetujui akun. Mengembalikan client token (Bearer) untuk endpoint portal. requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: { type: string } password: { type: string, format: password } responses: '200': { description: Token + profil klien } '401': { description: Kredensial salah } /auth/me: get: summary: Profil klien dari token tags: [Auth (Portal)] security: [{ ClientToken: [] }] responses: '200': { description: Profil klien } /auth/logout: post: summary: Logout (invalidasi token) tags: [Auth (Portal)] security: [{ ClientToken: [] }] responses: '200': { description: Logout berhasil } # ===================== API KEYS PORTAL (rencana — lihat issue #2) ===================== /api-keys: get: summary: Lihat API key sendiri (ter-mask) tags: [API Keys (Portal)] security: [{ ClientToken: [] }] responses: '200': { description: API key milik klien (1 per klien) } post: summary: Generate / regenerate API key (1 per klien, key lama dihapus) tags: [API Keys (Portal)] security: [{ ClientToken: [] }] description: Khusus klien `has_developer=true`. Full key hanya ditampilkan sekali. requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } responses: '201': { description: API key dibuat (full key tampil sekali) } '403': { description: has_developer=false } /api-keys/{id}: delete: summary: Cabut API key sendiri tags: [API Keys (Portal)] security: [{ ClientToken: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer } } responses: '200': { description: Dicabut } # ===================== REALTIME (Centrifugo) ===================== /realtime/token: get: summary: Token koneksi Centrifugo untuk chat tiket real-time tags: [Realtime] security: [{ ClientToken: [] }] description: | Mengembalikan JWT koneksi Centrifugo (TTL 60 menit). Pakai untuk connect ke server Centrifugo, lalu subscribe ke channel `chat:ticket.{id}` milik tiket Anda untuk menerima balasan admin/klien secara live. responses: '200': { description: "{ token, expires_in }" } /realtime/subscribe: get: summary: Token subscribe Centrifugo untuk channel chat tiket (scoped + cek kepemilikan) tags: [Realtime] security: [{ ClientToken: [] }] description: | Mengembalikan token subscribe Centrifugo (TTL 60 menit) khusus untuk channel `chat:ticket.{id}`. Backend memverifikasi tiket tsb milik klien dari token — jika bukan miliknya dikembalikan 403. Channel namespace `chat` tidak boleh mengaktifkan `allow_subscribe_for_client`, sehingga token scoped inilah satu-satunya cara subscribe (mencegah klien membaca chat tiket klien lain). parameters: - { name: channel, in: query, required: true, schema: { type: string }, description: "Channel, mis. `chat:ticket.5`." } responses: '200': { description: "{ token, channel, expires_in }" } '400': { description: Channel tidak valid } '403': { description: Tiket bukan milik Anda } '404': { description: Tiket tidak ditemukan } /realtime/transactions/subscribe: get: summary: Token subscribe Centrifugo untuk feed transaksi live (channel milik sendiri) tags: [Realtime] security: [{ ClientToken: [] }] description: | Mengembalikan token subscribe Centrifugo (TTL 60 menit) untuk channel `transactions:client.{id}` — hanya id klien dari token yang diterima. Meminta channel klien lain dikembalikan 403; channel di luar namespace `transactions` dikembalikan 400. Event yang mengalir di channel ini, semuanya berbentuk `{ event, data }`: `transaction.created`, `invoice.paid`, `invoice.expired`, `balance.updated`, `withdrawal.updated`. Connect dulu memakai token koneksi dari `/realtime/token` (tidak terikat channel). parameters: - { name: channel, in: query, required: true, schema: { type: string }, description: "Channel, mis. `transactions:client.7`." } responses: '200': { description: "{ token, channel, expires_in }" } '400': { description: Channel tidak valid } '403': { description: Channel ini bukan milik Anda } '503': { description: Realtime tidak terkonfigurasi } # ===================== TRANSAKSI (Portal, token klien) ===================== /me/transactions: get: summary: Daftar transaksi milik sendiri (portal) tags: [Transactions] security: [{ ClientToken: [] }] description: | Versi portal dari `/transactions` (yang memakai API key). client_id diambil dari token — parameter `client_id` diabaikan, jadi tidak ada cara melihat transaksi klien lain. Path sengaja berbeda dari `/transactions` supaya route API key tidak tertutup oleh route token portal. parameters: - { name: page, in: query, required: false, schema: { type: integer } } - { name: per_page, in: query, required: false, schema: { type: integer } } - { name: status, in: query, required: false, schema: { type: string } } responses: '200': { description: "Daftar transaksi + meta { total, current_page, per_page, last_page }" } /me/transactions/{id}: get: summary: Detail transaksi milik sendiri (portal) tags: [Transactions] security: [{ ClientToken: [] }] description: | Transaksi milik klien lain dijawab 404 — bukan 403 — supaya keberadaannya tidak bocor. parameters: - { name: id, in: path, required: true, schema: { type: integer } } responses: '200': { description: Detail transaksi } '404': { description: Tidak ditemukan / bukan milik Anda } # ===================== TICKETS (Portal, client-scoped) ===================== /tickets: get: summary: Daftar tiket milik sendiri tags: [Tickets (Portal)] security: [{ ClientToken: [] }] description: client_id diambil dari token — hanya tiket milik klien tsb yang dikembalikan. parameters: - { name: status, in: query, required: false, schema: { type: string } } responses: '200': { description: Daftar tiket } post: summary: Buat tiket (lapor issue) tags: [Tickets (Portal)] security: [{ ClientToken: [] }] requestBody: required: true content: application/json: schema: type: object required: [subject, description] properties: subject: { type: string } description: { type: string } category: { type: string, enum: [bug, feature_request, question, other] } priority: { type: string, enum: [low, medium, high] } responses: '201': { description: Tiket dibuat } /tickets/{id}: get: summary: Detail tiket + balasan tags: [Tickets (Portal)] security: [{ ClientToken: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer } } responses: '200': { description: "{ ticket, replies }" } '403': { description: Tiket bukan milik klien } /tickets/{id}/reply: post: summary: Balas tiket (is_admin=false) tags: [Tickets (Portal)] security: [{ ClientToken: [] }] description: Balasan disiarkan real-time ke channel `chat:ticket.{id}` via Centrifugo. parameters: - { name: id, in: path, required: true, schema: { type: integer } } requestBody: required: true content: application/json: schema: type: object required: [message] properties: message: { type: string } responses: '201': { description: Balasan ditambahkan } '403': { description: Tiket bukan milik klien } # ===================== PROPOSALS (Portal) ===================== /proposals: post: summary: Kirim proposal/konsultasi (klien login) tags: [Proposals (Portal)] security: [{ ClientToken: [] }] requestBody: required: true content: application/json: schema: type: object required: [nama, email, deskripsi] properties: nama: { type: string } perusahaan: { type: string } email: { type: string } whatsapp: { type: string } deskripsi: { type: string } responses: '201': { description: Proposal dikirim } # ===================== FEES ===================== /fees/rates: get: summary: Daftar tarif fee per channel tags: [Fees] responses: '200': { description: Daftar tarif } /fees/calculate: post: summary: Hitung fee untuk satu channel tags: [Fees] requestBody: required: true content: application/json: schema: type: object required: [amount, channel_code] properties: amount: { type: number } channel_code: { type: string, description: "mis. BCA, OVO, QRIS" } include_vat: { type: boolean } responses: '200': { description: Rincian fee } /fees/calculate/bulk: post: summary: Hitung fee untuk banyak channel sekaligus tags: [Fees] requestBody: required: true content: application/json: schema: type: object required: [amount, channels] properties: amount: { type: number } channels: { type: array, items: { type: string } } include_vat: { type: boolean } responses: '200': { description: Rincian fee per channel } /fees/calculate/vat: post: summary: Hitung VAT tags: [Fees] requestBody: required: true content: application/json: schema: type: object required: [amount] properties: amount: { type: number } is_inclusive: { type: boolean, description: "true bila amount sudah termasuk VAT" } responses: '200': { description: Rincian VAT } /fees/calculate/withdrawal: post: summary: Hitung biaya penarikan tags: [Fees] requestBody: required: true content: application/json: schema: type: object required: [amount, bank_code] properties: amount: { type: number } bank_code: { type: string } responses: '200': { description: Biaya penarikan } /fees/calculate/amount-to-pay: post: summary: Hitung jumlah yang harus dibayar agar merchant menerima nominal tertentu tags: [Fees] requestBody: required: true content: application/json: schema: type: object required: [net_amount, channel_code] properties: net_amount: { type: number, description: "Nominal bersih yang ingin diterima" } channel_code: { type: string } include_vat: { type: boolean } responses: '200': { description: Jumlah yang harus dibayar customer } # ===================== PAYMENTS ===================== /payments/methods: get: summary: Daftar metode pembayaran tersedia tags: [Payments] responses: '200': { description: Daftar metode } /payments/va: post: summary: Buat pembayaran Virtual Account tags: [Payments] requestBody: required: true content: application/json: schema: type: object required: [client_id, amount, bank_code, customer_name] properties: client_id: { type: integer } amount: { type: number } bank_code: { type: string, description: "BCA, BNI, BRI, MANDIRI, dll" } customer_name: { type: string } responses: '201': { description: VA dibuat (berisi nomor VA) } /payments/ewallet: post: summary: Buat pembayaran e-wallet tags: [Payments] requestBody: required: true content: application/json: schema: type: object required: [client_id, amount, channel_code] properties: client_id: { type: integer } amount: { type: number } channel_code: { type: string, description: "OVO, DANA, SHOPEEPAY, LINKAJA" } customer_phone: { type: string } success_url: { type: string } responses: '201': { description: Charge e-wallet dibuat (berisi link/deeplink bayar) } /payments/qris: post: summary: Buat pembayaran QRIS tags: [Payments] requestBody: required: true content: application/json: schema: type: object required: [client_id, amount] properties: client_id: { type: integer } amount: { type: number } responses: '201': { description: QRIS dibuat (berisi qr_string) } /payments/invoice: post: summary: Buat invoice (halaman pembayaran serbaguna) tags: [Payments] requestBody: required: true content: application/json: schema: type: object required: [amount] properties: client_id: { type: integer, description: "Opsional — diambil dari API key." } app_id: { type: integer, description: "Wajib bila client punya >1 app." } amount: { type: number, minimum: 1000 } external_id: type: string description: >- Referensi milik kamu (mis. id order). Disimpan di transaksi, bisa dicari via GET /transactions/external/{id}, dan dikirim balik di callback sebagai `client_external_id`. metadata: type: object description: >- Isi `callback_url` di sini agar Sarana mem-forward status ke endpoint kamu. `type_transaksi` (opsional) ditambahkan sebagai query `?via=` pada callback. properties: callback_url: { type: string } type_transaksi: { type: string } responses: '201': { description: Invoice dibuat (berisi invoice_url + transaction.transaction_id) } /payments/{id}/status: get: summary: Cek status pembayaran tags: [Payments] parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': { description: Status pembayaran } '404': { description: Tidak ditemukan } # ===================== TRANSACTIONS ===================== /transactions: post: summary: Catat transaksi baru tags: [Transactions] requestBody: required: true content: application/json: schema: type: object required: [client_id, total] properties: client_id: { type: integer } transaction_id: { type: string } total: { type: number } net_payment: { type: number } transaction_log: { type: object } status: { type: string } responses: '201': { description: Transaksi dibuat } /transactions/{id}: get: summary: Lihat transaksi by ID tags: [Transactions] parameters: - { name: id, in: path, required: true, schema: { type: integer } } responses: '200': { description: Detail transaksi } '404': { description: Tidak ditemukan } /transactions/external/{externalId}: get: summary: Lihat transaksi by external_id tags: [Transactions] parameters: - { name: externalId, in: path, required: true, schema: { type: string } } responses: '200': { description: Detail transaksi } '404': { description: Tidak ditemukan } # ===================== WITHDRAWALS ===================== /withdrawals: post: summary: Ajukan penarikan dana tags: [Withdrawals] description: | client_id diambil dari API key (`X-API-Key`) — tidak perlu dikirim di body. Jika akun di-set auto-approve, penarikan langsung diproses ke Xendit; jika tidak, statusnya pending menunggu approval admin. requestBody: required: true content: application/json: schema: type: object required: [jumlah, url] properties: jumlah: { type: number } biaya_penarikan: { type: number } by: { type: string, description: "Opsional; default 'client'" } url: { type: string, description: "Callback URL untuk notifikasi status penarikan" } responses: '201': { description: Penarikan diajukan (pending atau auto-approved tergantung akun) } '400': { description: Saldo kurang / sudah ada penarikan pending } '401': { description: API key tidak valid } /withdrawals/{id}: get: summary: Lihat penarikan by ID tags: [Withdrawals] parameters: - { name: id, in: path, required: true, schema: { type: integer } } responses: '200': { description: Detail penarikan } /withdrawals/external/{externalId}: get: summary: Lihat penarikan by external_id tags: [Withdrawals] parameters: - { name: externalId, in: path, required: true, schema: { type: string } } responses: '200': { description: Detail penarikan } /withdrawals/{id}/cancel: post: summary: Batalkan penarikan (hanya bila masih pending) tags: [Withdrawals] parameters: - { name: id, in: path, required: true, schema: { type: integer } } responses: '200': { description: Penarikan dibatalkan } '400': { description: Tidak bisa dibatalkan } # ===================== SETTLEMENTS (API key) ===================== /settlements: post: summary: Buat settlement/pencairan (client_id diambil dari API key) tags: [Settlements] requestBody: required: true content: application/json: schema: type: object required: [jumlah, tujuan] properties: jumlah: { type: number } tujuan: { type: string } by: { type: string } norek: { type: string } bank: { type: string } responses: '201': { description: Settlement dibuat } '400': { description: Saldo kurang / data tidak lengkap } '401': { description: API key tidak valid } # ===================== BANK CHANNELS ===================== /bank-channels/active: get: summary: Daftar channel/bank yang aktif tags: [Bank Channels] responses: '200': { description: Daftar channel aktif } /bank-channels/code/{code}: get: summary: Detail channel by kode tags: [Bank Channels] parameters: - { name: code, in: path, required: true, schema: { type: string } } responses: '200': { description: Detail channel } '404': { description: Tidak ditemukan } # ===================== ACCOUNT ===================== /account: get: summary: Profil client sendiri tags: [Account] description: | Profil client di balik API key (client_id diambil dari key, bukan request). Subset aman — tidak mengembalikan secret/token Xendit atau password hash. responses: '200': description: Profil client content: application/json: schema: { $ref: '#/components/schemas/AccountEnvelope' } '401': { description: API key tidak ada/invalid } '404': { description: Client tidak ditemukan } put: summary: Update profil client sendiri tags: [Account] description: | Perbarui field profil yang di-whitelist. Field lain (id, kode, saldo, has_developer, auto_approve_withdrawal, plan, xendit_*) diabaikan bila dikirim — tidak dapat diubah lewat surface ini. requestBody: content: application/json: schema: { $ref: '#/components/schemas/AccountUpdate' } responses: '200': description: Profil terupdate content: application/json: schema: { $ref: '#/components/schemas/AccountEnvelope' } '400': { description: Body tidak valid (mis. norek bukan angka) } '401': { description: API key tidak ada/invalid } '404': { description: Client tidak ditemukan } /account/balance: get: summary: Saldo client sendiri tags: [Account] description: "Alias `GET /clients/{id}/balance` dengan client_id dari API key." responses: '200': { description: Saldo client } '401': { description: API key tidak ada/invalid } /clients/{id}/balance: get: summary: Cek saldo client tags: [Account] parameters: - { name: id, in: path, required: true, schema: { type: integer } } responses: '200': { description: Saldo client } # ===================== CALLBACKS (webhook reference) ===================== /callback/payment: post: summary: "[Referensi] Format callback status pembayaran/invoice yang dikirim Sarana ke URL kamu" tags: [Callbacks] description: | Sarana mengirim `POST` ke `metadata.callback_url` kamu saat status invoice jadi **terminal**: `PAID`/`SETTLED` atau `EXPIRED`. Status lain (pending) TIDAK di-callback — poll `GET /payments/{id}/status`. Payload ini **tidak** membawa header `X-Callback-Token`. Cocokkan transaksi pada `transaction_id` (`INV-…`, dari response create) atau `client_external_id` (referensi kamu). Jangan andalkan `id` numerik — `id` selalu string hex Xendit. `status` dikirim UPPERCASE — normalkan sendiri. Balas `2xx` dan proses asinkron. requestBody: content: application/json: schema: type: object properties: id: { type: string, description: "Xendit invoice id (hex)." } external_id: { type: string, description: "= transaction_id (INV--)." } transaction_id: { type: string, description: "Referensi transaksi Sarana." } client_external_id: { type: string, description: "external_id yang kamu kirim saat create (bisa kosong)." } user_id: { type: string } status: { type: string, enum: [PAID, SETTLED, EXPIRED] } amount: { type: number } paid_amount: { type: number } adjusted_received_amount: { type: number, description: "Nominal bersih setelah fee." } biayavat: { type: number, description: "Biaya + VAT dihitung Sarana." } paid_at: { type: string } payment_method: { type: string } payment_channel: { type: string } metadata: { type: object } responses: '200': { description: Acknowledged } /callback/transaction: post: summary: "[Referensi] Callback status transaksi" tags: [Callbacks] responses: '200': { description: Acknowledged } /callback/withdrawal: post: summary: "[Referensi] Callback status penarikan" tags: [Callbacks] responses: '200': { description: Acknowledged } /callback/disbursement: post: summary: "[Referensi] Callback status disbursement/settlement" tags: [Callbacks] responses: '200': { description: Acknowledged }