# TemanQRIS API v1.3.4: dokumentasi lengkap > Versi Markdown dari https://temanqris.com/docs, satu file untuk dibaca LLM dan alat lain. Diperbarui 2026-11-01. Spesifikasi OpenAPI 3.1: https://temanqris.com/openapi.json Base URL `https://temanqris.com`. Autentikasi lewat header `X-API-Key`. Endpoint `/api/pay` dan `/api/widget` publik. ## Panduan singkat untuk AI agent Bagian ini cukup untuk menjalankan integrasi Payment Gateway dari invoice dibuat sampai lunas. Detail setiap langkah ada di bagian lain file ini, dan skema mesinnya di https://temanqris.com/openapi.json (`operationId`, contoh request dan response, serta daftar `code` di `components.schemas.Error`). **Dasar** - Base URL: `https://temanqris.com`. Body request dan response berupa JSON (`Content-Type: application/json`, maksimal 10 KB), kecuali upload gambar QRIS yang memakai multipart. - Autentikasi: header `X-API-Key: `. Jangan pernah menaruh key di URL atau query string. - Key live: 64 karakter heksadesimal dari https://temanqris.com/dashboard/settings. Key sandbox: `tq_test_` + 48 karakter heksadesimal dari console Sandbox di https://temanqris.com/dashboard/payment-gateway. Key hanya bisa dilihat pemilik akun setelah memasukkan password, jadi minta key ke pengguna dan simpan di environment variable server. - Webhook secret dibuat di tempat yang sama (live 64 karakter heksadesimal, sandbox `whsec_test_`). Tanpa secret, webhook tidak dikirim. - Panggil API dari server. Panggilan browser dari domain lain ditolak CORS. - Mulai dari key sandbox. Endpoint, body, dan error sama dengan live, tanpa KYC dan tanpa uang sungguhan. **Urutan panggilan** 1. `GET /api/payment-gateway/status`. Lanjut kalau `enabled` bernilai `true` dan `gateway_access` bernilai `available`. Key sandbox selalu boleh. 2. `POST /api/payment-gateway/payments` dengan header `Idempotency-Key` dan body minimal `{"order_id":"INV-0001","amount":150000}`. Tambahkan `webhook_url` (HTTPS publik). Simpan `payment_attempt_id`, `payment_link.orderId`, dan `payment_link.totalAmount`. Tunjukkan `https://temanqris.com` + `payment_link.url` atau `payment_link.qr_image` ke pelanggan. 3. Khusus sandbox: `POST /api/payment-gateway/sandbox/payments/{order_id}/simulate` dengan `{"result":"paid"}` (atau `expired`, `late_paid`). 4. Terima webhook `payment.confirmed`. Hitung `sha256=` + HMAC-SHA256(raw body, webhook secret) dalam hex, bandingkan dengan `X-TemanQRIS-Signature` memakai fungsi waktu konstan, buang duplikat dengan `idempotency_key`, dan di production tolak `X-TemanQRIS-Mode: sandbox`. Cocokkan `data.total_amount` dengan `totalAmount` yang disimpan. Balas 2xx dalam 10 detik. 5. Kalau webhook belum datang, polling setiap 3 sampai 5 detik: live `GET /api/qris/orders/{order_id}` (tidak memakai kuota), sandbox `GET /api/payment-gateway/sandbox/payments/{order_id}` (maksimal 120 per menit). Selesai saat `status` bernilai `paid`. QR berlaku 1 jam secara bawaan, tapi pembayaran sampai 60 menit setelahnya tetap dicocokkan, jadi order `expired` masih bisa menjadi `paid`. **Idempotency** - `Idempotency-Key` wajib di setiap create: 1 sampai 256 karakter ASCII yang bisa dicetak, satu key untuk satu percobaan, misalnya `INV-0001-attempt-01`. - Timeout, error jaringan, 5xx, atau 429: kirim ulang request yang sama persis dengan key yang sama. Kalau invoice sudah ada, jawabannya `200` dengan `existing: true`. - Key yang sama dengan isi berbeda ditolak `422 idempotency_key_reused`. - Satu `order_id` hanya untuk satu pembayaran gateway. Untuk mencoba lagi setelah gagal atau kedaluwarsa, pakai `order_id` baru (misalnya `INV-0001-R2`) dan key baru. **Retry dan backoff** - `429` dengan header `Retry-After` (`gateway_rate_limited`): tunggu sebanyak detik itu, lalu kirim ulang request yang sama. - `429 gateway_open_limit`: terlalu banyak invoice terbuka. Tunggu beberapa menit, invoice kedaluwarsa dalam 1 jam secara bawaan. - `429` tanpa `code` (`Rate limit exceeded`): kuota harian habis sampai 00.00 UTC. Jangan diulang hari itu. - `500` dan `503`: ulangi dengan jeda yang bertambah (1, 2, 4, 8, 16 detik, maksimal 5 kali) memakai `Idempotency-Key` yang sama, lalu cek status order. Pengecualian: `503 gateway_amount_reservation_lost` dan `503 provider_timeout` minta `order_id` baru. Set timeout klien minimal 25 detik, karena TemanQRIS menjawab dalam 20 detik. Atau kirim `X-Request-Timeout-Ms` berisi batas tunggu Anda dalam milidetik, dan TemanQRIS berhenti 1 detik sebelumnya. - 4xx lain: jangan diulang tanpa mengubah request. **Bercabang dengan `code`** | `code` | HTTP | Tindakan | | --- | --- | --- | | `invalid_order_id`, `missing_idempotency_key`, `invalid_idempotency_key`, `invalid_amount`, `amount_too_large`, `invalid_fee_amount`, `fee_exceeds_base`, `amount_below_provider_fee`, `total_amount_too_large`, `gateway_total_exceeds_cap`, `invalid_webhook_url`, `invalid_callback_url`, `sandbox_invalid_result` | 400 | Perbaiki input sesuai `code`. Jangan ulang request yang sama. | | `KYC_REQUIRED` | 403 | Berhenti. Pengguna perlu menyelesaikan KYC. Sambil menunggu, pakai key sandbox. | | `account_inactive` | 403 | Berhenti dan minta pengguna menghubungi support. | | `new_account_daily_amount_cap` | 403 | Jangan diulang hari ini. Sisa nominal adalah `limit - used`. Reset 00.00 UTC. | | `wrong_gateway_mode` | 403 | Endpoint sandbox butuh key `tq_test_`. | | `sandbox_payment_not_found` | 404 | Periksa `order_id` dan key yang dipakai. | | `gateway_order_id_used` | 409 | Buat ulang dengan `order_id` baru dan key baru. | | `idempotency_key_in_use`, `gateway_creation_in_progress` | 409 | Tunggu 2 sampai 5 detik, lalu kirim ulang request yang sama. | | `sandbox_invalid_transition` | 409 | Invoice sudah final. Pakai `late_paid` untuk invoice kedaluwarsa, atau buat invoice baru. | | `idempotency_key_reused` | 422 | Kirim ulang isi request yang asli, atau pakai key baru untuk isi baru. | | `gateway_rate_limited` | 429 | Tunggu `Retry-After` detik, lalu kirim ulang request yang sama. | | `gateway_open_limit` | 429 | Tunggu beberapa menit. | | `rate_limited` | 429 | Perlambat polling sandbox. | | `gateway_amount_busy` | 503 | Ulangi beberapa menit lagi, atau ubah nominal sedikit. | | `gateway_amount_reservation_lost` | 503 | Buat ulang dengan `order_id` baru. | | `provider_timeout` | 503 | Penyedia lambat, tidak ada QR. Buat ulang dengan `order_id` baru. | | `webhook_dns_unavailable` | 503 | Ulangi request yang sama. | | angka (`400`, `413`, `500`) atau tanpa `code` | semua | Baca status HTTP dan `error`. 5xx: ulangi dengan key yang sama, lalu cek status order. | Endpoint jalur manual (`/api/qris/*`) tidak membawa `code` string. Untuk endpoint itu, bercabang dengan status HTTP dan teks `error` sesuai tabel "Error endpoint QRIS dan Payment Link" di bagian Kode Error. ## Daftar isi - [Mulai: API QRIS TemanQRIS: Dokumentasi](https://temanqris.com/docs#intro) - [Mulai: Autentikasi](https://temanqris.com/docs#auth) - [Mulai: Quick Start](https://temanqris.com/docs#quickstart) - [Mulai: Rate Limit dan Kuota](https://temanqris.com/docs#limits) - [Mulai: Kode Error](https://temanqris.com/docs#errors) - [Use Case: Ringkasan](https://temanqris.com/docs#use-cases) - [Use Case: Checkout toko online dengan gateway otomatis](https://temanqris.com/docs#use-case-checkout) - [Use Case: Invoice lewat payment link yang dibagikan di WhatsApp](https://temanqris.com/docs#use-case-invoice) - [Use Case: Kasir toko dan UMKM dengan QRIS dinamis](https://temanqris.com/docs#use-case-cashier) - [Use Case: Uji di Sandbox, lalu checklist go live](https://temanqris.com/docs#use-case-sandbox) - [Use Case: Kedaluwarsa, bayar telat, dan retry aman dengan Idempotency-Key](https://temanqris.com/docs#use-case-retry) - [Use Case: Memasang widget pembayaran di website](https://temanqris.com/docs#use-case-widget) - [Use Case: Top up saldo dan tagihan SaaS](https://temanqris.com/docs#use-case-saas) - [Use Case: Banyak outlet dalam satu akun](https://temanqris.com/docs#use-case-outlets) - [Use Case: Rekonsiliasi harian](https://temanqris.com/docs#use-case-reconcile) - [Use Case: Alur cURL lengkap](https://temanqris.com/docs#use-case-curl) - [QRIS: Upload QRIS Statis](https://temanqris.com/docs#upload) - [QRIS: Cek QRIS](https://temanqris.com/docs#my-qris) - [QRIS: Generate QRIS Dinamis](https://temanqris.com/docs#generate) - [QRIS: Render QR](https://temanqris.com/docs#render) - [Payment Link: Payment Link dan Status Order](https://temanqris.com/docs#order-status) - [Payment Link: Buat Payment Link](https://temanqris.com/docs#payment-link-create) - [Payment Link: Daftar Payment Link](https://temanqris.com/docs#payment-links) - [Payment Link: Cek Status Order](https://temanqris.com/docs#check-order) - [Payment Link: Daftar Order](https://temanqris.com/docs#list-orders) - [Payment Link: Verifikasi Order](https://temanqris.com/docs#verify-order) - [Payment Link: Batalkan Order](https://temanqris.com/docs#cancel-order) - [Payment Link: Arsipkan Payment Link](https://temanqris.com/docs#payment-link-delete) - [Payment Link: Pelanggan Sudah Bayar (publik)](https://temanqris.com/docs#payment-confirm) - [Payment Link: Status Link (publik)](https://temanqris.com/docs#pay-status) - [Payment Gateway: Gateway Otomatis](https://temanqris.com/docs#gateway) - [Payment Gateway: Buat Pembayaran Gateway](https://temanqris.com/docs#gateway-create) - [Payment Gateway: Status Gateway](https://temanqris.com/docs#gateway-status) - [Payment Gateway: Mode Sandbox](https://temanqris.com/docs#gateway-sandbox) - [Payment Gateway: Biaya, Saldo Tertahan, dan Refund](https://temanqris.com/docs#gateway-money) - [Webhook: Webhook](https://temanqris.com/docs#webhooks) - [Webhook: Verifikasi Signature](https://temanqris.com/docs#webhook-verify) - [Webhook: Webhook Gateway dan Callback](https://temanqris.com/docs#gateway-webhooks) - [Widget: Widget.js](https://temanqris.com/docs#widget) - [Widget: Opsi Widget](https://temanqris.com/docs#widget-options) - [Widget: Widget API](https://temanqris.com/docs#widget-api) - [Akun: Penggunaan API](https://temanqris.com/docs#usage) - [Changelog](https://temanqris.com/docs#changelog) ## API QRIS TemanQRIS: Dokumentasi URL: https://temanqris.com/docs#intro REST API QRIS untuk mengubah QRIS statis menjadi QRIS dinamis dengan nominal custom, fee, dan payment link yang bisa dibagikan. Autentikasi memakai header `X-API-Key`. Ada dua cara menerima pembayaran: - **Manual.** QRIS milik Anda sendiri. Dana langsung masuk ke rekening atau e-wallet Anda, lalu Anda (atau sistem Anda) memverifikasi pembayaran. - **Otomatis (Payment Gateway).** TemanQRIS menerbitkan QRIS dengan kode unik, mencocokkan pembayaran sendiri, lalu mengirim webhook `payment.confirmed`. Dana masuk ke wallet TemanQRIS Anda. **Base URL:** `https://temanqris.com` | Prefix | Isi | Autentikasi | | --- | --- | --- | | `/api/qris` | QRIS, payment link, order, penggunaan API | `X-API-Key` live atau JWT dashboard | | `/api/payment-gateway` | Payment Gateway otomatis dan Sandbox | `X-API-Key` live, `X-API-Key` sandbox (`tq_test_`), atau JWT | | `/api/pay` | Halaman bayar publik (status link, tombol Sudah Bayar) | Tanpa autentikasi | | `/api/widget` | Widget pembayaran yang dipasang di website | Tanpa autentikasi, memakai Merchant ID | Baru di v1.3.4: biaya layanan gateway 0,8% dari `amount` untuk semua nominal, dibulatkan ke atas. QR gateway berlaku 1 jam secara bawaan dan bisa diatur admin. Pembayaran gateway live tidak memakai kuota harian; batasnya 60 invoice per menit per merchant (bawaan, `429 gateway_rate_limited` dengan header `Retry-After`). --- ## Autentikasi URL: https://temanqris.com/docs#auth Hampir semua endpoint butuh autentikasi. Pengecualiannya endpoint publik untuk pelanggan: `/api/pay/*` (halaman bayar) dan `/api/widget/*` (widget). Ambil API key di [Dashboard Settings](/dashboard/settings). | Cara | Header | Kapan dipakai | | --- | --- | --- | | API key live | `X-API-Key: YOUR_API_KEY` | Integrasi server Anda. Key berupa 64 karakter heksadesimal. | | API key sandbox | `X-API-Key: tq_test_...` | Hanya untuk Payment Gateway mode Sandbox. Lihat [Mode Sandbox](#gateway-sandbox). | | JWT dashboard | `Authorization: Bearer YOUR_JWT` | Sesi dashboard, atau server yang sudah memegang token login. Cookie `tq_token` dari dashboard juga diterima. | **Aturan penting** - API key hanya dibaca dari header `X-API-Key`, tidak pernah dari query string. - Panggil API dari backend. Endpoint `/api/qris` dan `/api/payment-gateway` memakai daftar origin CORS yang ketat, jadi panggilan dari browser di domain lain ditolak dengan `403 CORS: origin not allowed`. Frontend cukup menerima URL pembayaran dari server Anda. - Sandbox key (`tq_test_`) hanya diterima di endpoint Payment Gateway (buat pembayaran, status, cek invoice sandbox, simulasi). Di endpoint lain key ini dianggap tidak dikenal (401). - Key disimpan dalam bentuk hash. Untuk melihat key lengkap, buka Dashboard Settings dan masukkan password. Membuat ulang (rotate) key juga meminta password dan key lama langsung berhenti bekerja. **Response saat gagal** | Status | Body | Artinya | | --- | --- | --- | | 401 | `{"error":"API key required (send it in the x-api-key header)"}` | Header `X-API-Key` tidak dikirim. | | 401 | `{"error":"Invalid API key"}` | Key salah, sudah di-rotate, atau sandbox key dipakai di endpoint non-gateway. | | 401 | `{"error":"Access token required"}` | JWT tidak dikirim. | | 403 | `{"error":"Account is deactivated"}` | Akun dinonaktifkan. | | 403 | `{"error":"Invalid or expired token"}` | JWT tidak valid atau kedaluwarsa (berlaku 7 hari). | | 403 | `{"error":"CORS: origin not allowed"}` | Dipanggil dari browser di domain yang tidak diizinkan. | > **Keamanan API key.** Jangan taruh key di frontend, aplikasi mobile, atau repository. Simpan di environment variable server. Kalau key bocor, rotate dari Dashboard Settings. Contoh panggilan pertama (cek apakah key Anda bekerja): ```bash curl https://temanqris.com/api/qris/my-qris \ -H "X-API-Key: YOUR_API_KEY" ``` ```js // Node.js 18+ const res = await fetch('https://temanqris.com/api/qris/my-qris', { headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); console.log(res.status, await res.json()); ``` ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); var_dump($status, json_decode($body, true)); ``` ```python import os import requests r = requests.get( "https://temanqris.com/api/qris/my-qris", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, timeout=15, ) print(r.status_code, r.json()) ``` --- ## Quick Start URL: https://temanqris.com/docs#quickstart Pilih jalur yang paling cocok. Keduanya bisa dipakai bersamaan di satu akun. **Jalur Manual (QRIS milik Anda)** 1. **Upload QRIS statis.** Upload gambar atau string QRIS statis Anda lewat `POST /api/qris/upload`. Cukup sekali. 2. **Buat QRIS dinamis.** Panggil `POST /api/qris/generate` atau `POST /api/qris/payment-link` dengan nominal dan `order_id`. 3. **Bagikan atau pasang.** Kirim link `https://temanqris.com/p/{link_code}` ke pelanggan, atau pasang widget di website. 4. **Verifikasi.** Setelah dana masuk ke rekening atau e-wallet Anda, panggil `POST /api/qris/orders/:orderId/verify`. **Jalur Otomatis (Payment Gateway)** 1. **Coba di Sandbox.** Buka [Payment Gateway](/dashboard/payment-gateway), pindah ke Sandbox, lalu ambil key `tq_test_`. Tidak perlu KYC. 2. **Buat pembayaran.** `POST /api/payment-gateway/payments` dengan `order_id`, `amount`, dan header `Idempotency-Key`. 3. **Terima webhook.** Verifikasi signature, lalu tandai order lunas saat menerima `payment.confirmed`. 4. **Pindah ke Live.** Setelah KYC disetujui, ganti key dan webhook secret ke versi live. Endpoint dan kode Anda tetap sama. --- ## Rate Limit dan Kuota URL: https://temanqris.com/docs#limits ### Kuota harian per tier | Tier | Request per hari (bawaan) | QRIS statis tersimpan | Fitur | | --- | --- | --- | --- | | Free | 50 | 1 (upload ulang akan mengganti QRIS lama) | Fitur dasar | | Premium | 1.000 | Sesuai paket (bawaan 100) | Semua fitur dan dukungan prioritas | Kuota ini untuk fitur QRIS manual. Angka kuota bisa diubah admin. Angka yang berlaku untuk akun Anda selalu ada di `GET /api/qris/usage` dan di header response. **Endpoint yang memakai kuota harian:** - `POST /api/qris/upload` - `POST /api/qris/generate` - `POST /api/qris/payment-link` - `DELETE /api/qris/payment-link/:id` - `POST /api/qris/orders/:orderId/verify` - `POST /api/qris/orders/:orderId/cancel` Endpoint baca seperti `my-qris`, `render`, `payment-links`, `orders`, `usage`, dan `GET /api/payment-gateway/status` tidak memakai kuota. `POST /api/payment-gateway/payments` dengan key live (atau JWT dashboard) juga tidak memakai kuota harian. Setiap pembayaran gateway sudah dikenai biaya layanan, jadi batasnya per menit, bukan per hari. Lihat [Batas Payment Gateway live](#limits-batas-payment-gateway-live). Panggilan itu tetap tercatat di statistik `GET /api/qris/usage`. Dengan key `tq_test_`, panggilan yang sama memakai kuota Sandbox. **Cara hitungnya** - Kuota direset setiap hari pukul **00.00 UTC (07.00 WIB)**. - Request yang ditolak karena input salah (4xx) tetap dihitung. Hanya error server (5xx) yang dikembalikan ke kuota. - Setiap response yang memakai kuota membawa header: | Header | Isi | | --- | --- | | `X-RateLimit-Limit` | Batas harian Anda | | `X-RateLimit-Remaining` | Sisa kuota hari ini | | `X-RateLimit-Reset` | Waktu reset dalam Unix timestamp (detik), akhir hari UTC | Saat kuota habis: ```json { "error": "Rate limit exceeded", "message": "You have exceeded your daily limit of 50 requests", "tier": "free", "limit": 50, "used": 50, "resetTime": "Midnight (server time)" } ``` Status `429`. Tunggu sampai reset, atau upgrade ke Premium. ### Batas Payment Gateway live Berlaku untuk `POST /api/payment-gateway/payments` dengan key live atau JWT dashboard, dan untuk Widget API dengan `payment_mode: "gateway"`. Semua angka di bawah adalah nilai bawaan dan bisa diubah admin. | Batas | Nilai bawaan | Kalau terlewat | | --- | --- | --- | | Invoice baru per menit | 60 per merchant per menit (khusus API, bukan widget) | `429` dengan `code: "gateway_rate_limited"` dan header `Retry-After` | | Invoice yang masih terbuka | 50 per merchant | `429` dengan `code: "gateway_open_limit"` | | Total nominal harian untuk akun yang baru lolos KYC | Tidak aktif. Kalau diaktifkan admin, berlaku selama 14 hari pertama setelah KYC disetujui | `403` dengan `code: "new_account_daily_amount_cap"` | **Batas per menit** - Dihitung per merchant dalam jendela satu menit UTC. Jendela baru dimulai setiap pergantian menit. - Semua request create dihitung, termasuk yang ditolak karena input salah (4xx) dan replay dengan `Idempotency-Key` yang sama. Hanya error server (5xx) yang dikembalikan. - Response yang lolos membawa header berikut. Angkanya untuk jendela per menit, bukan kuota harian: | Header | Isi | | --- | --- | | `X-RateLimit-Limit` | Batas invoice per menit Anda | | `X-RateLimit-Remaining` | Sisa di menit ini | | `X-RateLimit-Reset` | Awal menit berikutnya dalam Unix timestamp (detik) | Saat batas terlewat, status `429` datang dengan header `Retry-After` (detik sampai menit berikutnya) dan tanpa header `X-RateLimit-*`: ```json { "error": "Too many gateway payments created in a short time. Try again shortly.", "code": "gateway_rate_limited", "limit": 60, "window_seconds": 60, "retry_after_seconds": 18 } ``` Tunggu sesuai `Retry-After` (sama dengan `retry_after_seconds`), lalu kirim ulang request yang sama dengan `Idempotency-Key` yang sama. Invoice tidak dibuat saat `429`, jadi tidak ada yang dobel. **Batas nominal harian akun baru** - Tidak aktif secara bawaan. Admin bisa mengaktifkannya dengan batas rupiah per hari. - Hanya berlaku untuk merchant yang KYC-nya disetujui kurang dari 14 hari (bawaan, bisa diatur 1 sampai 365 hari). Setelah itu batas ini tidak berlaku lagi. - Yang dijumlahkan adalah `amount` (nominal dasar) dari setiap invoice gateway live baru per tanggal UTC. Reset 00.00 UTC (07.00 WIB). - Replay dengan `Idempotency-Key` yang sama untuk invoice yang sudah ada tidak dihitung lagi dan tidak pernah ditolak oleh batas ini. Request yang gagal membuat invoice juga tidak dihitung. - `used` berisi total nominal yang sudah terpakai hari ini, belum termasuk request yang ditolak. ```json { "error": "Daily payment gateway amount limit reached for a newly verified account", "code": "new_account_daily_amount_cap", "limit": 5000000, "used": 4900000, "resetTime": "Midnight (UTC)" } ``` Status `403`. Jangan diulang di hari yang sama. Tunggu reset, buat invoice dengan nominal yang masih muat, atau pakai jalur manual. Di Widget API, body `403` ini hanya berisi `error` dan `code`. ### Batas lain | Batas | Nilai | Kalau terlewat | | --- | --- | --- | | Ukuran body JSON | 10 KB | `413` | | Kuota Sandbox | 500 panggilan per hari, terpisah dari kuota live | `429` dengan `"mode": "sandbox"` | | Cek invoice Sandbox | 120 per menit per merchant, tidak memakai kuota harian | `429` dengan `code: "rate_limited"` | | Halaman bayar `/p/:code` | 60 per menit per IP | `429` | | `GET /api/pay/:code` | 120 per menit per IP | `429` | | `POST /api/pay/:code/confirm` | 30 per menit per IP | `429` | | `POST /api/widget/generate` | 60 per menit per IP. Mode manual juga memakai kuota harian tier merchant. Mode gateway tidak memakai kuota harian. | `429` | | `POST /api/widget/confirm/:orderId` | 30 per menit per IP | `429` | > **Tips.** Pantau pemakaian dengan `GET /api/qris/usage`, dan baca `X-RateLimit-Remaining` di setiap response untuk angka yang paling akurat. --- ## Kode Error URL: https://temanqris.com/docs#errors ### Bentuk response error Semua error dikirim sebagai JSON dengan field `error` (pesan). Ada tiga bentuk yang perlu Anda kenali: **Error Payment Gateway dan Sandbox** selalu membawa `code` berupa string. Pakai `code` untuk logika program, jangan mencocokkan teks `error`. ```json { "error": "fee amount must not exceed base amount", "code": "fee_exceeds_base" } ``` **Error endpoint QRIS dan Payment Link** (`/api/qris/*`) membawa `error` dan kadang `message`, tanpa `code`. Contoh: ```json { "success": false, "error": "Order not found", "message": "No order found with order_id \"INV-999\" for your account." } ``` **Error umum** (JSON rusak, body terlalu besar, error server) membawa `code` berupa **angka** status HTTP: ```json { "error": "Internal Server Error", "code": 500 } ``` Pesan error server (5xx) tidak pernah berisi detail internal. Kalau dapat 5xx, ulangi request yang sama. Untuk Payment Gateway, ulangi dengan `Idempotency-Key` yang sama supaya tidak membuat invoice ganda. ### Tabel kode error | HTTP | `code` | Endpoint | Artinya | Yang perlu dilakukan | | --- | --- | --- | --- | --- | | 400 | `invalid_order_id` | Gateway, Sandbox | `order_id` kosong atau lebih dari 30 karakter. | Kirim `order_id` 1 sampai 30 karakter. | | 400 | `missing_idempotency_key` | Gateway, Sandbox | Header `Idempotency-Key` tidak dikirim. | Tambahkan header. | | 400 | `invalid_idempotency_key` | Gateway, Sandbox | Key kosong, lebih dari 256 karakter, atau ada karakter di luar ASCII yang bisa dicetak. | Pakai key ASCII 1 sampai 256 karakter, misalnya `INV-2026-0001-a1`. | | 400 | `invalid_amount` | Gateway, Sandbox | `amount` bukan bilangan bulat positif. | Kirim angka bulat, misalnya `150000` atau `"150000"`. Jangan pakai titik, koma, atau desimal. | | 400 | `amount_too_large` | Gateway, Sandbox | `amount` di atas Rp1.000.000. | Pecah tagihan, atau pakai jalur manual. | | 400 | `invalid_fee_amount` | Gateway, Sandbox | `fee_amount` bukan bilangan bulat 0 atau lebih. | Kirim angka bulat. | | 400 | `fee_exceeds_base` | Gateway, Sandbox | `fee_amount` lebih besar dari `amount`. | Turunkan fee, maksimal sama dengan `amount`. | | 400 | `amount_below_provider_fee` | Gateway, Sandbox | Biaya layanan gateway menghabiskan seluruh `amount + fee_amount`, merchant tidak menerima apa pun. | Naikkan nominal. Biaya layanan 0,8% dari `amount` dibulatkan ke atas (minimal Rp1), jadi `amount + fee_amount` minimal Rp2. | | 400 | `total_amount_too_large`, `gateway_total_exceeds_cap` | Gateway, Sandbox | `amount + fee_amount + kode unik` melewati batas total (bawaan Rp1.000.999). | Kurangi `amount` atau `fee_amount`. | | 400 | `invalid_webhook_url` | Gateway, Sandbox | URL tidak valid, bukan HTTPS di production, host tidak ada, atau mengarah ke alamat non-publik. | Pakai URL HTTPS publik. | | 400 | `invalid_callback_url` | Gateway, Sandbox | `callback_url` bukan URL http(s) yang valid. | Perbaiki URL. | | 400 | `sandbox_invalid_result` | Sandbox simulasi | `result` bukan `paid`, `expired`, atau `late_paid`. | Kirim salah satu nilai itu. | | 400 | `amount_too_small` | Widget API (gateway) | Nominal terlalu kecil untuk pembayaran otomatis. | Naikkan nominal. | | 400 | `400` (angka) | Semua | Body JSON rusak. | Periksa JSON dan header `Content-Type: application/json`. | | 403 | `KYC_REQUIRED` | Gateway live, Widget API (gateway) | KYC belum disetujui. Body juga berisi `kyc_status`. | Selesaikan verifikasi di [Dashboard Verifikasi](/dashboard/verification). Sambil menunggu, pakai Sandbox. | | 403 | `account_inactive` | Gateway, Sandbox | Akun merchant tidak aktif. | Hubungi support. | | 403 | `wrong_gateway_mode` | Sandbox (cek, simulasi) | Endpoint sandbox dipanggil dengan key live. | Pakai key `tq_test_`. | | 403 | `new_account_daily_amount_cap` | Gateway live, Widget API (gateway) | Total nominal invoice hari ini sudah mencapai batas untuk akun yang baru lolos KYC. Body API berisi `limit`, `used`, dan `resetTime`. | Jangan diulang hari ini. Tunggu reset 07.00 WIB, kecilkan nominal, atau pakai jalur manual. | | 404 | `sandbox_payment_not_found` | Sandbox | Tidak ada invoice sandbox dengan `order_id` itu di akun Anda. | Periksa `order_id` dan key yang dipakai. | | 409 | `gateway_order_id_used` | Gateway, Sandbox | `order_id` sudah punya pembayaran (aktif, gagal, atau kedaluwarsa), atau dipakai pembayaran lain. Juga muncul saat key yang sama mengulang pembayaran yang sudah gagal atau kedaluwarsa. | Buat pembayaran baru dengan `order_id` baru, misalnya `INV-2026-0001-R2`. | | 409 | `idempotency_key_in_use` | Gateway, Sandbox | Key yang sama sedang dipakai request lain untuk order ini. | Kirim ulang request yang sama persis. | | 409 | `gateway_creation_in_progress` | Gateway | Request pertama dengan key ini masih diproses. | Tunggu 2 sampai 5 detik, lalu kirim ulang request yang sama. | | 409 | `gateway_attempt_terminal` | Widget API (gateway) | Pembayaran untuk `idempotency_key` ini sudah gagal atau kedaluwarsa. | Pakai `idempotency_key` baru. | | 409 | `sandbox_invalid_transition` | Sandbox simulasi | Invoice sudah dibayar atau sudah kedaluwarsa, jadi hasil itu tidak berlaku. | Pakai `late_paid` untuk invoice kedaluwarsa, atau buat invoice baru. | | 409 | `sandbox_webhook_pending`, `sandbox_no_webhook_event`, `sandbox_no_webhook_url`, `sandbox_no_webhook_secret` | Console Sandbox (kirim ulang webhook) | Kirim ulang ditolak: webhook sebelumnya masih antre, belum ada simulasi, invoice tanpa `webhook_url`, atau secret sandbox belum bisa disiapkan. | Tunggu, simulasikan dulu, atau buat invoice dengan `webhook_url`. | | 422 | `idempotency_key_reused` | Gateway, Sandbox | Key yang sama sudah dipakai untuk request dengan isi berbeda. | Kirim ulang isi request yang asli, atau pakai key baru. | | 429 | `gateway_rate_limited` | Gateway live | Terlalu banyak invoice dibuat dalam satu menit (bawaan 60). Body berisi `limit`, `window_seconds`, dan `retry_after_seconds`. | Tunggu sesuai header `Retry-After`, lalu kirim ulang request yang sama dengan `Idempotency-Key` yang sama. | | 429 | `gateway_open_limit` | Gateway | Terlalu banyak invoice gateway yang masih terbuka. | Tunggu sebagian dibayar atau kedaluwarsa (1 jam secara bawaan). | | 429 | `rate_limited` | Sandbox (cek invoice) | Lebih dari 120 cek per menit. | Perlambat polling, misalnya setiap 3 sampai 5 detik. | | 429 | (tanpa `code`) | Endpoint QRIS yang memakai kuota, Sandbox | Kuota harian habis (`Rate limit exceeded`). Kuota Sandbox membawa `"mode": "sandbox"`. | Tunggu reset 07.00 WIB atau upgrade. | | 503 | `gateway_amount_busy` | Gateway | Semua kode unik untuk nominal ini sedang dipakai invoice lain. | Coba lagi beberapa menit lagi, atau ubah nominal sedikit. | | 503 | `gateway_amount_reservation_lost` | Gateway | Pembayaran terlalu lama disiapkan dan reservasi nominal lepas. | Buat pembayaran baru dengan `order_id` baru. | | 503 | `provider_timeout` | Gateway | Penyedia pembayaran tidak menjawab dalam 20 detik. Tidak ada QR yang dibuat. Response berisi `retryable: true`. | Buat pembayaran baru dengan `order_id` baru, misalnya `INV-2026-0001-R2`. | | 503 | `webhook_dns_unavailable` | Gateway, Sandbox | DNS sedang bermasalah saat memeriksa `webhook_url`. | Kirim ulang request yang sama. | | 503 | (tanpa `code`) | Gateway | `Payment gateway mode is disabled by admin`, atau gateway belum dikonfigurasi. | Coba lagi nanti, pakai Sandbox, atau jalur manual. | | 503 | (tanpa `code`) | Semua yang memakai kuota, Gateway | `Rate limiter unavailable`: kuota atau batas per menit sementara tidak bisa diperiksa. | Ulangi beberapa detik lagi dengan request yang sama. | | 500 | `500` (angka) atau tanpa `code` | Semua | Error server. | Ulangi request yang sama. Untuk gateway pakai `Idempotency-Key` yang sama lalu cek status order. | ### Error endpoint QRIS dan Payment Link | HTTP | `error` | Endpoint | Yang perlu dilakukan | | --- | --- | --- | --- | | 400 | `QRIS string or image is required`, `Invalid QRIS format` (+ `details`), `Could not decode QR code...`, `File too large. Max size is 1.5MB`, `Image dimensions are unsupported or too large` | `upload` | Kirim QRIS statis yang jelas, JPG/PNG/GIF/BMP/TIFF, maksimal 1,5 MB dan 6000 px. | | 400 | `Please upload a static QRIS code...` | `upload` | QRIS dinamis tidak bisa dijadikan template. Pakai QRIS statis dari bank atau e-wallet. | | 400 | `Merchant limit reached` | `upload` (Premium) | Hapus salah satu QRIS di dashboard. | | 400 | `Valid amount is required...`, `Amount is required and must be a whole number...` | `generate`, `payment-link` | `amount` bilangan bulat 1 sampai 100.000.000. | | 400 | `Invalid fee_type...`, `Invalid fee_value`, `fee_type is required when fee_value is provided`, `Percent fee cannot exceed 100`, `Rupiah fee must be a whole number` | `generate`, `payment-link` | Periksa aturan fee di [Generate QRIS](#generate). | | 400 | `Order ID too long (max 30 chars)`, `Order ID must be a string` | `generate`, `payment-link` | Pakai string 1 sampai 30 karakter. | | 400 | `Only image files are allowed (jpeg, jpg, png, gif, bmp, tiff)` | `upload` | Kirim JPG, PNG, GIF, BMP, atau TIFF. WebP belum didukung. | | 400 | `status must be one of: pending, awaiting_confirmation, paid, expired, cancelled` | `orders` | Pakai salah satu status itu, atau kosongkan `status`. | | 400 | `Order ID already exists` (+ `existing_link_code`) | `payment-link` | Order ini sudah punya link. Ambil statusnya dengan `GET /orders/:orderId`. | | 400 | `Invalid webhook URL...`, `Webhook URL must use HTTPS`, `Invalid callback URL format` | `generate`, `payment-link` | Pakai URL HTTPS publik. | | 400 | `Order already verified`, `Order expired`, `Order cancelled` | `verify` | Order tidak bisa diverifikasi di status ini. | | 400 | `Cannot cancel paid order`, `Order already cancelled` | `cancel` | Tidak perlu tindakan. | | 404 | `No QRIS found...` / `No active QRIS found...` (400 di `payment-link`) | `generate`, `payment-link` | Upload QRIS statis dulu. | | 404 | `Order not found` | `orders`, `verify`, `cancel` | Periksa `order_id`. | | 404 | `Payment link not found or unauthorized` | `DELETE payment-link` | Pakai `id` numerik dari response, bukan `link_code`. | | 409 | `Order ID sudah dipakai untuk pembayaran gateway` | `generate` | Pakai `order_id` lain untuk QRIS manual. | | 409 | `Order ID sudah dibayar atau menunggu konfirmasi` | `generate` | Nominal order yang sudah dibayar tidak bisa diubah. Pakai `order_id` baru. | | 409 | `Gateway order requires automatic settlement reconciliation` | `verify` | Order gateway dikonfirmasi otomatis. Jangan verifikasi manual. | | 409 | `Order state changed` | `cancel` | Status berubah saat dibatalkan (misalnya baru saja dibayar). Cek ulang order. | | 409 | `Paid or confirmed payment links cannot be removed` | `DELETE payment-link` | Link yang sudah dibayar atau menunggu konfirmasi tidak bisa diarsipkan. | | 503 | `code: "webhook_dns_unavailable"` | `generate` | DNS sedang bermasalah saat memeriksa `webhook_url`. Kirim ulang request yang sama. | --- ## Use Case URL: https://temanqris.com/docs#use-cases Bagian ini menunjukkan cara memakai TemanQRIS API dari sisi sistem Anda, dari order dibuat sampai pembayaran dinyatakan sah di backend. Setiap skenario berisi untuk siapa, alur langkah demi langkah, endpoint yang dipakai, contoh request dan response lengkap, cara menangani webhook (dengan verifikasi signature di Node.js dan PHP), serta kasus khusus yang perlu disiapkan. > **Penting.** Pakai API key hanya dari backend. Frontend, aplikasi mobile, atau halaman checkout cukup menerima URL pembayaran dari server Anda. | Use case | Mode | Endpoint utama | Yang dilihat pelanggan | Catatan backend | | --- | --- | --- | --- | --- | | [Checkout toko online](#use-case-checkout) | Otomatis | `POST /api/payment-gateway/payments` | Halaman bayar `/p/{linkCode}` atau QR di halaman Anda | Tandai lunas dari webhook `payment.confirmed`. | | [Invoice via WhatsApp](#use-case-invoice) | Manual | `POST /api/qris/payment-link` | Link pembayaran di chat | Pakai nomor invoice sebagai `order_id`. Verifikasi setelah dana masuk. | | [Kasir dan UMKM](#use-case-cashier) | Manual | `POST /api/qris/generate` | QR di layar kasir | Kasir menekan Lunas setelah notifikasi dana masuk. | | [Sandbox ke Live](#use-case-sandbox) | Otomatis | Endpoint gateway dengan key `tq_test_` | Halaman bayar berlabel SANDBOX | Uji semua jalur sebelum memegang uang sungguhan. | | [Kedaluwarsa dan retry](#use-case-retry) | Keduanya | `Idempotency-Key`, `GET /api/qris/orders/:orderId` | Tidak ada | Retry tanpa invoice ganda, tangani bayar telat. | | [Widget di website](#use-case-widget) | Manual | `widget.js` | Tombol Bayar dengan QRIS | Jangan percaya redirect, cek webhook atau API. | | [Top up dan tagihan SaaS](#use-case-saas) | Otomatis | Gateway + `GET /api/qris/orders/:orderId` | Halaman bayar | Tambah saldo pengguna sekali saja per pembayaran. | | [Banyak outlet](#use-case-outlets) | Manual (Premium) | `qris_id`, `qris_data_id` | QR outlet masing-masing | Satu akun, banyak QRIS statis. | | [Rekonsiliasi harian](#use-case-reconcile) | Keduanya | `GET /api/qris/orders`, `GET /api/qris/payment-links` | Tidak ada | Cocokkan order lunas dengan catatan Anda. | | [Alur cURL lengkap](#use-case-curl) | Manual | Semua endpoint jalur manual | Link pembayaran | Coba seluruh alur dari terminal sebelum menulis kode. | | Order dari WhatsApp, Telegram, atau CRM | Manual atau Otomatis | `POST /api/qris/payment-link` atau gateway | Link di chat | Simpan `link_code` dan status order di sistem internal. | **Siapkan sekali di awal (semua skenario)** 1. Simpan API key di environment variable server. 2. Buat **webhook secret** di Dashboard Settings. Tanpa secret, TemanQRIS **tidak mengirim webhook sama sekali**. 3. Buat tabel kecil `webhook_events` dengan kolom `idempotency_key` yang unik. Dipakai untuk membuang webhook duplikat. 4. Untuk jalur manual: panggil `GET /api/qris/my-qris`. Kalau `has_qris` bernilai `false`, upload sekali via `POST /api/qris/upload`. 5. Untuk jalur otomatis: cek `GET /api/payment-gateway/status`. Lanjut ke live hanya kalau `gateway_access` bernilai `available`. --- ## Checkout toko online dengan gateway otomatis URL: https://temanqris.com/docs#use-case-checkout **Untuk siapa:** toko online, marketplace kecil, atau aplikasi yang ingin pembayaran terkonfirmasi otomatis tanpa admin mengecek mutasi. Butuh KYC yang sudah disetujui. **Alur** 1. Pelanggan menekan Bayar. Backend Anda membuat order di database dengan status `pending` dan menyimpan `Idempotency-Key` untuk percobaan ini, misalnya `INV-2026-0001-1`. Simpan key **sebelum** request dikirim. 2. Backend memanggil `POST /api/payment-gateway/payments`. 3. Simpan dari response: `payment_attempt_id`, `payment_link.linkCode`, `payment_link.totalAmount`, `payment_link.expiresAt`, `payment_link.net_amount`. 4. Arahkan pelanggan ke `https://temanqris.com` + `payment_link.url`, atau tampilkan `payment_link.qr_image` di halaman Anda bersama nominal `totalAmount` dan hitung mundur sampai `expiresAt`. 5. Pelanggan membayar **tepat** sebesar `totalAmount` (sudah termasuk kode unik). QR berlaku 1 jam secara bawaan. 6. TemanQRIS mencocokkan pembayaran, mengkreditkan wallet Anda (`net_amount`, tertahan sampai 00.00 WIB hari berikutnya), lalu mengirim webhook `payment.confirmed`. 7. Endpoint webhook Anda memverifikasi signature, memastikan mode `live`, membuang duplikat, mencocokkan `order_id` dan `total_amount`, lalu menandai order lunas dalam satu transaksi database. 8. Pelanggan bisa diarahkan ke `callback_url`. Halaman itu hanya menampilkan status dari database Anda sendiri, bukan dari parameter URL. 9. Cadangan: kalau webhook belum masuk 5 menit setelah `expiresAt`, cek `GET /api/qris/orders/INV-2026-0001`. **Endpoint:** `POST /api/payment-gateway/payments`, `GET /api/qris/orders/:orderId`, webhook `payment.confirmed`. **Request** ```bash curl -X POST https://temanqris.com/api/payment-gateway/payments \ -H "X-API-Key: YOUR_API_KEY" \ -H "Idempotency-Key: INV-2026-0001-1" \ -H "Content-Type: application/json" \ -d '{ "order_id": "INV-2026-0001", "amount": 150000, "fee_amount": 0, "title": "Pesanan INV-2026-0001", "description": "2 item, Toko Kopi Senja", "webhook_url": "https://tokokamu.id/webhooks/temanqris", "callback_url": "https://tokokamu.id/orders/INV-2026-0001/selesai" }' ``` **Response `201 Created`** ```json { "success": true, "existing": false, "payment_mode": "gateway", "payment_attempt_id": "2de4a4d5-3b0e-4b7a-9a3e-4c9f7a2b1e55", "payment_link": { "id": 456, "linkCode": "aZ3kQ9mP2xLw", "orderId": "INV-2026-0001", "amount": 150000, "feeAmount": 0, "provider_fee_amount": 1200, "net_amount": 148800, "uniqueCode": 321, "totalAmount": 150321, "description": "2 item, Toko Kopi Senja", "expiresAt": "2026-10-17T03:15:00.000Z", "payable_until": "2026-10-17T04:15:00.000Z", "url": "/p/aZ3kQ9mP2xLw", "qr_image": "data:image/png;base64,iVBOR..." }, "mode": "live" } ``` **Kode backend (Node.js, Express)** ```js const express = require('express'); const crypto = require('crypto'); const app = express(); const BASE = 'https://temanqris.com'; app.post('/checkout/:orderId/pay', express.json(), async (req, res) => { const order = await db.orders.find(req.params.orderId); // order milik Anda const key = order.idempotency_key || `${order.id}-1`; await db.orders.update(order.id, { idempotency_key: key }); // simpan dulu const response = await fetch(`${BASE}/api/payment-gateway/payments`, { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY, 'Idempotency-Key': key, 'Content-Type': 'application/json' }, body: JSON.stringify({ order_id: order.id, amount: order.subtotal, title: `Pesanan ${order.id}`, webhook_url: 'https://tokokamu.id/webhooks/temanqris', callback_url: `https://tokokamu.id/orders/${order.id}/selesai` }) }); const data = await response.json().catch(() => ({})); if (!response.ok) { return res.status(502).json({ error: data.code || `http_${response.status}` }); } await db.orders.update(order.id, { attempt_id: data.payment_attempt_id, expected_total: data.payment_link.totalAmount, expires_at: data.payment_link.expiresAt }); res.json({ pay_url: BASE + data.payment_link.url }); }); ``` **Webhook (Node.js, Express)** ```js function verifySignature(rawBody, header, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); if (typeof header !== 'string' || header.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req.body, req.get('X-TemanQRIS-Signature'), process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.status(401).json({ error: 'invalid signature' }); } const event = JSON.parse(req.body.toString('utf8')); if (req.get('X-TemanQRIS-Mode') !== 'live' || event.data.mode !== 'live') { return res.status(400).json({ error: 'not a live webhook' }); } if (event.event !== 'payment.confirmed' || event.data.payment_mode !== 'gateway') { return res.sendStatus(204); } try { await db.transaction(async (tx) => { const isNew = await tx.webhookEvents.insertIgnore(event.idempotency_key); // kolom unik if (!isNew) return; // duplikat const order = await tx.orders.findForUpdate(event.data.order_id); if (!order || order.expected_total !== event.data.total_amount) { await tx.anomalies.insert({ event }); // selidiki manual return; } if (order.status !== 'paid') { await tx.orders.update(order.id, { status: 'paid', paid_at: event.data.paid_at, net_amount: event.data.net_amount }); } }); res.sendStatus(204); } catch (err) { console.error(err); res.sendStatus(500); // TemanQRIS akan mengirim ulang } }); ``` **Webhook (PHP)** ```php PDO::ERRMODE_EXCEPTION]); try { $pdo->beginTransaction(); $ins = $pdo->prepare('INSERT IGNORE INTO webhook_events (idempotency_key) VALUES (?)'); $ins->execute([$event['idempotency_key']]); if ($ins->rowCount() === 1) { $q = $pdo->prepare('SELECT id, status, expected_total FROM orders WHERE id = ? FOR UPDATE'); $q->execute([$event['data']['order_id']]); $order = $q->fetch(PDO::FETCH_ASSOC); if (!$order || (int) $order['expected_total'] !== (int) $event['data']['total_amount']) { error_log('TemanQRIS anomaly: ' . $raw); // selidiki manual } elseif ($order['status'] !== 'paid') { $paidAt = (new DateTime($event['data']['paid_at']))->format('Y-m-d H:i:s'); $u = $pdo->prepare("UPDATE orders SET status = 'paid', paid_at = ?, net_amount = ? WHERE id = ?"); $u->execute([$paidAt, $event['data']['net_amount'], $order['id']]); } } $pdo->commit(); http_response_code(204); } catch (Throwable $e) { $pdo->rollBack(); error_log($e->getMessage()); http_response_code(500); // TemanQRIS akan mengirim ulang } ``` **Kasus khusus** | Kejadian | Yang terjadi | Yang perlu Anda lakukan | | --- | --- | --- | | Request create timeout | Anda tidak tahu invoice sudah dibuat atau belum. | Kirim ulang **request yang sama** dengan **key yang sama**. Anda akan mendapat `200` dengan `existing: true` dan invoice yang sama. | | `503 provider_timeout` | Penyedia pembayaran sedang lambat. TemanQRIS berhenti menunggu di detik ke-20 dan tidak membuat QR. | Tampilkan "Coba lagi" ke pelanggan, lalu buat percobaan baru dengan `order_id` baru. | | `409 gateway_creation_in_progress` | Request pertama masih diproses. | Tunggu 2 sampai 5 detik, kirim ulang yang sama. | | `403 KYC_REQUIRED` | KYC belum disetujui. | Tampilkan metode bayar lain, atau pakai jalur manual sementara. | | `503 gateway_amount_busy` | Semua kode unik untuk nominal itu sedang dipakai. | Coba lagi beberapa menit kemudian. Jangan ganti `order_id` hanya karena ini. | | Pelanggan membayar nominal berbeda | Pembayaran tidak cocok dengan invoice mana pun dan masuk antrean tinjauan admin. | Minta pelanggan menghubungi support dengan bukti transfer. Jangan membuat invoice baru dengan nominal yang ditebak. | | Pelanggan membayar setelah QR kedaluwarsa | Masih dicocokkan selama masa tenggang (60 menit secara bawaan setelah kedaluwarsa). Webhook tetap `payment.confirmed`. | Jangan anggap order gagal sebelum `payable_until` (`expiresAt` + 60 menit secara bawaan). Lihat [Kedaluwarsa dan retry](#use-case-retry). | | Webhook Anda sedang mati | Dikirim ulang 3 kali (30 detik, 2 menit, 10 menit). Setelah itu admin diberi tahu. | Jalankan polling cadangan `GET /api/qris/orders/:orderId` untuk order yang masih menunggu. | | Webhook datang dua kali | Kunci `idempotency_key` sama. | Tabel `webhook_events` membuang duplikat. | | Pelanggan minta refund | Refund tidak tersedia lewat API. | Hubungi support. Kalau refund dilakukan di sisi penyedia, admin meninjau dan menarik kembali dana dari saldo Anda (saldo tertahan dulu, lalu saldo tersedia). | | `callback_url` dibuka tanpa bayar | Siapa pun bisa membuka URL itu. | Halaman callback hanya membaca status dari database Anda. | --- ## Invoice lewat payment link yang dibagikan di WhatsApp URL: https://temanqris.com/docs#use-case-invoice **Untuk siapa:** freelancer, agency, jasa, toko yang menerima order lewat chat. Memakai QRIS Anda sendiri (jalur manual), jadi dana langsung masuk ke rekening atau e-wallet Anda tanpa potongan TemanQRIS. **Alur** 1. Admin atau bot membuat invoice di sistem Anda, misalnya `INV-0815`. 2. Backend memanggil `POST /api/qris/payment-link` dengan `order_id` = nomor invoice, `description`, `webhook_url`, dan `callback_url` (opsional). 3. Simpan `payment_link.id` dan `payment_link.link_code`. Kalau `existing` bernilai `true`, invoice ini sudah punya link aktif dengan nominal dan fee yang sama, dan link itulah yang dikembalikan. 4. Kirim `https://temanqris.com` + `payment_link.url` ke pelanggan lewat WhatsApp. 5. Pelanggan membuka link, scan QR, membayar, lalu menekan **Sudah Bayar**. 6. Status menjadi `awaiting_confirmation`. TemanQRIS mengirim webhook `payment.awaiting_confirmation` dan email ke Anda. 7. Admin mengecek mutasi rekening atau e-wallet. Kalau dana masuk dengan nominal yang benar, backend memanggil `POST /api/qris/orders/INV-0815/verify`. 8. Status menjadi `paid` dan webhook `payment.confirmed` terkirim. Kirim konfirmasi ke pelanggan. **Endpoint:** `POST /api/qris/payment-link`, `POST /api/qris/generate` (cadangan), `GET /api/qris/orders/:orderId`, `POST /api/qris/orders/:orderId/verify`, `POST /api/qris/orders/:orderId/cancel`. **Request** ```bash curl -X POST https://temanqris.com/api/qris/payment-link \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 750000, "description": "Desain logo, invoice INV-0815", "order_id": "INV-0815", "webhook_url": "https://studiokamu.id/webhooks/temanqris", "callback_url": "https://studiokamu.id/terima-kasih" }' ``` **Response `200 OK`** ```json { "success": true, "payment_link": { "id": 812, "link_code": "K7QPMX4TRB2N", "order_id": "INV-0815", "url": "/p/K7QPMX4TRB2N", "amount": 750000, "fee_type": "none", "fee_value": 0, "fee_amount": 0, "total_amount": 750000, "description": "Desain logo, invoice INV-0815", "merchant_name": "STUDIO KAMU", "webhook_url": "https://studiokamu.id/webhooks/temanqris", "callback_url": "https://studiokamu.id/terima-kasih", "expires_at": "2026-10-18T03:00:00.000Z", "qr_image": "data:image/png;base64,iVBOR..." } } ``` Contoh pesan WhatsApp: `Halo Kak, tagihan INV-0815 sebesar Rp750.000 bisa dibayar lewat QRIS di sini: https://temanqris.com/p/K7QPMX4TRB2N. Link berlaku sampai 18 Okt 10.00 WIB.` **Verifikasi setelah dana masuk** ```bash curl -X POST https://temanqris.com/api/qris/orders/INV-0815/verify \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "payer_name": "Budi Santoso", "payer_note": "Masuk BCA 10.12 WIB" }' ``` **Webhook (Node.js, Express)** ```js const express = require('express'); const crypto = require('crypto'); const app = express(); function verifySignature(rawBody, header, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); if (typeof header !== 'string' || header.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req.body, req.get('X-TemanQRIS-Signature'), process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); if (req.get('X-TemanQRIS-Mode') !== 'live' || event.data.mode !== 'live') return res.sendStatus(400); if (!(await db.webhookEvents.insertIgnore(event.idempotency_key))) return res.sendStatus(204); const { order_id: orderId, amount } = event.data; // amount = nominal dasar, tanpa fee if (event.event === 'payment.awaiting_confirmation') { await db.invoices.update(orderId, { status: 'cek_mutasi' }); await notifyFinance(`Pelanggan bilang sudah bayar ${orderId} sebesar Rp${amount}. Cek mutasi.`); } if (event.event === 'payment.confirmed') { await db.invoices.update(orderId, { status: 'lunas', paid_at: event.data.paid_at }); await sendWhatsApp(orderId, 'Pembayaran diterima. Terima kasih!'); } res.sendStatus(204); }); ``` **Webhook (PHP)** ```php PDO::ERRMODE_EXCEPTION]); $ins = $pdo->prepare('INSERT IGNORE INTO webhook_events (idempotency_key) VALUES (?)'); $ins->execute([$event['idempotency_key']]); if ($ins->rowCount() === 0) { http_response_code(204); // duplikat exit; } $orderId = $event['data']['order_id']; if ($event['event'] === 'payment.awaiting_confirmation') { $pdo->prepare("UPDATE invoices SET status = 'cek_mutasi' WHERE invoice_no = ?")->execute([$orderId]); } elseif ($event['event'] === 'payment.confirmed') { $pdo->prepare("UPDATE invoices SET status = 'lunas', paid_at = NOW() WHERE invoice_no = ?")->execute([$orderId]); } http_response_code(204); ``` **Kasus khusus** | Kejadian | Yang perlu Anda lakukan | | --- | --- | | Response `existing: true` | Invoice ini sudah punya link aktif dengan nominal dan fee yang sama. Kirim ulang link itu. Link milik invoice lain tidak pernah dipakai ulang. | | `400 Order ID already exists` | Invoice ini sudah punya link. Ambil link dan statusnya dengan `GET /api/qris/orders/INV-0815`, kirim ulang link lama. | | Link kedaluwarsa sebelum dibayar | Link berlaku 24 jam (Free) atau 7 hari (Premium). Untuk memperpanjang, panggil `POST /api/qris/generate` dengan `order_id` dan nominal yang sama. Link yang sama aktif lagi dengan masa berlaku baru. Kirim ulang `webhook_url` dan `callback_url`, karena nilai yang tidak dikirim akan dikosongkan. | | Pelanggan menekan Sudah Bayar padahal belum | Status tetap `awaiting_confirmation`. Jangan verifikasi sebelum dana terlihat di mutasi. Kalau tidak pernah masuk, batalkan dengan `POST /api/qris/orders/INV-0815/cancel`. | | Pelanggan transfer kurang atau lebih | QR dinamis mengunci nominal, tapi transfer manual bisa berbeda. Selesaikan dengan pelanggan, lalu verifikasi atau batalkan. | | Invoice dibatalkan | `POST /api/qris/orders/INV-0815/cancel`. Link tidak bisa dibayar lagi lewat halaman bayar. | | Order sudah `paid` | Memanggil `POST /api/qris/generate` lagi dengan isi yang sama aman: order dikembalikan dengan `existing: true` dan status tetap `paid`. Nominal atau fee berbeda ditolak `409`. | | Mau konfirmasi otomatis | Pakai gateway, tapi buat invoice gateway hanya saat pelanggan siap membayar (QR gateway berlaku 1 jam secara bawaan), misalnya saat pelanggan membalas "BAYAR" di chat. | --- ## Kasir toko dan UMKM dengan QRIS dinamis URL: https://temanqris.com/docs#use-case-cashier **Untuk siapa:** kasir toko, warung, kafe, atau aplikasi POS yang ingin QR dengan nominal pas untuk setiap transaksi, tanpa pelanggan mengetik nominal sendiri. Jalur manual, dana masuk langsung ke QRIS Anda. **Alur** 1. Kasir menyelesaikan keranjang di POS. POS punya nomor transaksi, misalnya `POS-0817-0042`. 2. POS memanggil `POST /api/qris/generate` dengan `amount` dan `order_id`. 3. Tampilkan `qr_image` di layar pelanggan, tablet, atau cetak di struk. Tampilkan juga `total_amount`. 4. Pelanggan scan dan membayar. Nominal sudah terkunci di QR. 5. Kasir melihat notifikasi dana masuk di aplikasi bank atau e-wallet merchant dengan nominal yang sama. 6. Kasir menekan **Lunas** di POS. POS memanggil `POST /api/qris/orders/POS-0817-0042/verify`. 7. Kalau pelanggan batal, POS memanggil `POST /api/qris/orders/POS-0817-0042/cancel`. 8. Tutup kasir: tarik `GET /api/qris/orders?status=paid` untuk laporan harian. **Endpoint:** `POST /api/qris/generate`, `POST /api/qris/orders/:orderId/verify`, `POST /api/qris/orders/:orderId/cancel`, `GET /api/qris/orders`. **Request** ```bash curl -X POST https://temanqris.com/api/qris/generate \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 48500, "order_id": "POS-0817-0042", "webhook_url": "https://pos.kopisenja.id/webhooks/temanqris" }' ``` **Response `200 OK`** ```json { "success": true, "qris": "00020101021226...6304A1B2", "qr_image": "data:image/png;base64,iVBOR...", "amount": 48500, "fee": null, "fee_amount": 0, "total_amount": 48500, "expires_at": "2026-10-18T03:20:00.000Z", "payment_link": { "id": 9031, "link_code": "R2TNQ8MKXH5P", "order_id": "POS-0817-0042", "url": "/p/R2TNQ8MKXH5P", "amount": 48500, "fee_type": "none", "fee_value": 0, "fee_amount": 0, "total_amount": 48500, "merchant_name": "KOPI SENJA", "webhook_url": "https://pos.kopisenja.id/webhooks/temanqris", "callback_url": null, "expires_at": "2026-10-18T03:20:00.000Z" } } ``` **Tombol Lunas di POS** ```bash curl -X POST https://temanqris.com/api/qris/orders/POS-0817-0042/verify \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "payer_note": "Dicek kasir Rina, notifikasi GoPay Merchant 10.21" }' ``` ```json { "success": true, "message": "Payment verified successfully", "order": { "order_id": "POS-0817-0042", "amount": 48500, "status": "paid", "paid_at": "2026-10-17T03:21:04.000Z", "confirmed_by": "api" } } ``` **Webhook untuk server pusat (Node.js)** Berguna kalau beberapa kasir melapor ke satu server laporan. Event yang masuk adalah `payment.confirmed` setelah verify, atau `payment.awaiting_confirmation` kalau pelanggan membuka link dan menekan Sudah Bayar. ```js const express = require('express'); const crypto = require('crypto'); const app = express(); function verifySignature(rawBody, header, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); if (typeof header !== 'string' || header.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req.body, req.get('X-TemanQRIS-Signature'), process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); if (req.get('X-TemanQRIS-Mode') !== 'live' || event.data.mode !== 'live') return res.sendStatus(400); if (event.event === 'payment.confirmed' && (await db.webhookEvents.insertIgnore(event.idempotency_key))) { await db.sales.markPaid(event.data.order_id, event.data.paid_at); } res.sendStatus(204); }); ``` **Webhook untuk server pusat (PHP)** ```php PDO::ERRMODE_EXCEPTION]); $ins = $pdo->prepare('INSERT IGNORE INTO webhook_events (idempotency_key) VALUES (?)'); $ins->execute([$event['idempotency_key']]); if ($ins->rowCount() === 1) { $pdo->prepare("UPDATE sales SET status = 'paid', paid_at = NOW() WHERE pos_no = ?") ->execute([$event['data']['order_id']]); } } http_response_code(204); ``` **Kasus khusus** | Kejadian | Yang perlu Anda lakukan | | --- | --- | | Dua pelanggan membayar nominal sama di waktu berdekatan | Jalur manual tidak membedakan pembayaran dengan nominal sama. Cocokkan dengan jam notifikasi atau nama pembayar. Kalau sering terjadi, pakai gateway yang menambahkan kode unik. | | Kasir salah menekan Lunas | Tidak ada endpoint untuk membatalkan verifikasi. Tekan Lunas hanya setelah notifikasi dana terlihat. | | Pelanggan pergi sebelum bayar | `POST /api/qris/orders/:orderId/cancel`. | | Kuota harian habis di jam ramai | Free hanya 50 request per hari dan setiap QR memakai satu. Toko aktif sebaiknya Premium (1.000 per hari). Pantau `X-RateLimit-Remaining`. | | Mau membebankan biaya tambahan | Kirim `fee_type` (`rupiah` atau `percent`) dan `fee_value`. Pelanggan membayar `total_amount`. | | Internet kasir mati | Sediakan QRIS statis cetak sebagai cadangan. Pelanggan mengetik nominal sendiri, kasir mencatat manual. | | Retry `generate` karena timeout | Aman. `order_id` dan nominal yang sama mengembalikan link yang sama. Kalau order sudah `paid`, response berisi `existing: true` dan status tetap `paid`. | --- ## Uji di Sandbox, lalu checklist go live URL: https://temanqris.com/docs#use-case-sandbox **Untuk siapa:** developer yang membangun integrasi gateway. Sandbox tidak butuh KYC, tidak memakai uang sungguhan, dan tidak menyentuh saldo. **Alur** 1. Buka [Payment Gateway](/dashboard/payment-gateway), pindah ke **Sandbox**. Tampilkan sandbox key (`tq_test_...`) dan webhook secret sandbox (`whsec_test_...`) dengan password. 2. Simpan keduanya di environment server **uji**: `TEMANQRIS_API_KEY=tq_test_...`, `TEMANQRIS_WEBHOOK_SECRET=whsec_test_...`, `TEMANQRIS_EXPECTED_MODE=sandbox`. 3. Buat invoice dengan `POST /api/payment-gateway/payments` memakai sandbox key. Response berisi `"mode": "sandbox"` dan `url` berbentuk `/p/sb_...`. 4. Buka halaman bayar. Ada label **SANDBOX, jangan dibayar**. QR sengaja tidak bisa dibayar. 5. Simulasikan hasil: `POST /api/payment-gateway/sandbox/payments/:order_id/simulate` dengan `{"result":"paid"}`, atau tombol di halaman bayar. 6. Webhook `payment.confirmed` masuk sekitar satu menit kemudian dengan `X-TemanQRIS-Mode: sandbox`, ditandatangani dengan secret sandbox. 7. Cek status dengan `GET /api/payment-gateway/sandbox/payments/:order_id`. 8. Ulangi untuk `expired` dan `late_paid`, lalu uji kasus error dan duplikat (lihat checklist). **Request** ```bash curl -X POST https://temanqris.com/api/payment-gateway/payments \ -H "X-API-Key: tq_test_KEY_SANDBOX_ANDA" \ -H "Idempotency-Key: TEST-0001-1" \ -H "Content-Type: application/json" \ -d '{ "order_id": "TEST-0001", "amount": 150000, "webhook_url": "https://staging.tokokamu.id/webhooks/temanqris" }' ``` **Response `201 Created`** ```json { "success": true, "existing": false, "payment_mode": "gateway", "payment_attempt_id": "7b0f3c2e-9a41-4d5e-8c3b-1f2e3d4c5b6a", "payment_link": { "id": 31, "linkCode": "sb_Xk2mQ9aZ3pLw7RtY", "orderId": "TEST-0001", "amount": 150000, "feeAmount": 0, "provider_fee_amount": 1200, "net_amount": 148800, "uniqueCode": 87, "totalAmount": 150087, "description": null, "expiresAt": "2026-10-17T03:15:00.000Z", "payable_until": "2026-10-17T04:15:00.000Z", "url": "/p/sb_Xk2mQ9aZ3pLw7RtY", "qr_image": "data:image/png;base64,iVBOR..." }, "mode": "sandbox", "paid_late": false } ``` **Simulasi bayar** ```bash curl -X POST https://temanqris.com/api/payment-gateway/sandbox/payments/TEST-0001/simulate \ -H "X-API-Key: tq_test_KEY_SANDBOX_ANDA" \ -H "Content-Type: application/json" \ -d '{"result":"paid"}' ``` ```json { "success": true, "existing": true, "payment_mode": "gateway", "payment_attempt_id": "7b0f3c2e-9a41-4d5e-8c3b-1f2e3d4c5b6a", "payment_link": { "linkCode": "sb_Xk2mQ9aZ3pLw7RtY", "orderId": "TEST-0001", "totalAmount": 150087, "...": "..." }, "mode": "sandbox", "paid_late": false, "changed": true, "webhook_queued": true, "webhook_reason": null, "status": "paid", "paid_at": "2026-10-17T03:05:41.000Z" } ``` **Webhook sandbox yang diterima** ```json { "event": "payment.confirmed", "idempotency_key": "3b1c9d2e-4f5a-4b6c-8d7e-9f0a1b2c3d4e", "timestamp": "2026-10-17T03:06:12.000Z", "data": { "order_id": "TEST-0001", "link_code": "sb_Xk2mQ9aZ3pLw7RtY", "amount": 150000, "description": null, "status": "paid", "paid_at": "2026-10-17T03:05:41.000Z", "created_at": "2026-10-17T03:00:02.000Z", "mode": "sandbox", "payment_mode": "gateway", "payment_attempt_id": "7b0f3c2e-9a41-4d5e-8c3b-1f2e3d4c5b6a", "base_amount": 150000, "fee_amount": 0, "unique_code": 87, "total_amount": 150087, "provider_fee_amount": 1200, "net_amount": 148800, "confirmed_by": "gateway", "paid_late": false } } ``` **Satu handler untuk dua lingkungan (Node.js)** Mode yang diharapkan diatur lewat environment, jadi kode yang sama aman dipakai di staging (sandbox) dan production (live). ```js const express = require('express'); const crypto = require('crypto'); const app = express(); const EXPECTED_MODE = process.env.TEMANQRIS_EXPECTED_MODE || 'live'; // 'sandbox' hanya di staging function verifySignature(rawBody, header, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); if (typeof header !== 'string' || header.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req.body, req.get('X-TemanQRIS-Signature'), process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); if (req.get('X-TemanQRIS-Mode') !== EXPECTED_MODE || event.data.mode !== EXPECTED_MODE) { return res.sendStatus(400); // production menolak sandbox, staging menolak live } if (!(await db.webhookEvents.insertIgnore(event.idempotency_key))) return res.sendStatus(204); if (event.event === 'payment.confirmed') { await db.orders.markPaid(event.data.order_id, { total: event.data.total_amount, late: event.data.paid_late === true }); } res.sendStatus(204); }); ``` **Satu handler untuk dua lingkungan (PHP)** ```php PDO::ERRMODE_EXCEPTION]); $ins = $pdo->prepare('INSERT IGNORE INTO webhook_events (idempotency_key) VALUES (?)'); $ins->execute([$event['idempotency_key']]); if ($ins->rowCount() === 1 && $event['event'] === 'payment.confirmed') { $pdo->prepare("UPDATE orders SET status = 'paid', paid_at = NOW() WHERE id = ?") ->execute([$event['data']['order_id']]); } http_response_code(204); ``` **Checklist sebelum go live** Uji di sandbox: - [ ] Invoice baru mengembalikan `201`, dan request ulang dengan key dan isi yang sama mengembalikan `200` dengan `existing: true`. - [ ] Key yang sama dengan isi berbeda (misalnya `amount` lain) ditangani sebagai bug: `422 idempotency_key_reused`. - [ ] `order_id` yang sudah kedaluwarsa memakai `order_id` baru (`409 gateway_order_id_used`). - [ ] `result: "paid"` membuat order lunas tepat sekali. - [ ] Tombol **Kirim ulang webhook** di console tidak membuat order diproses dua kali (idempotency key sama). - [ ] `result: "expired"` membuat order Anda kedaluwarsa, tapi tetap bisa menerima bayar telat. - [ ] `result: "late_paid"` membuat order lunas walau sudah ditandai kedaluwarsa. - [ ] Signature salah ditolak dengan `401`. Body diverifikasi dalam bentuk mentah (raw), bukan JSON yang diserialisasi ulang. - [ ] Error dibaca dari field `code`, bukan dari teks `error`. - [ ] Endpoint webhook membalas 2xx dalam kurang dari 10 detik. Pindah ke live: - [ ] KYC disetujui: `GET /api/payment-gateway/status` dengan key live menunjukkan `gateway_access: "available"` dan `enabled: true`. - [ ] Webhook secret live sudah dibuat di Dashboard Settings. - [ ] Production memakai key live dan webhook secret live. Key `tq_test_` dan `whsec_test_` tidak ada di server production. - [ ] Handler production hanya menerima `X-TemanQRIS-Mode: live` dan `data.mode: "live"`. - [ ] `webhook_url` live memakai HTTPS dan bisa dijangkau dari internet. - [ ] Ada polling cadangan `GET /api/qris/orders/:orderId` untuk order yang tertunda. - [ ] Tim tahu bahwa dana masuk sebagai saldo tertahan dan bisa ditarik mulai 00.00 WIB hari berikutnya. **Kasus khusus** | Kejadian | Yang perlu Anda lakukan | | --- | --- | | `403 wrong_gateway_mode` | Endpoint simulasi dan cek invoice sandbox butuh key `tq_test_`. | | `401 Invalid API key` dengan key `tq_test_` | Sandbox key hanya diterima di endpoint Payment Gateway. Endpoint `/api/qris/*` butuh key live. | | Webhook sandbox tidak datang | Lihat `webhook_reason` di response simulasi: `no_webhook_url` (invoice tanpa `webhook_url`) atau `no_webhook_secret`. | | Dua invoice sandbox punya total sama | Normal. Kode unik sandbox diacak tanpa reservasi. Di live, total selalu unik. | | `429` dengan `"mode": "sandbox"` | Kuota 500 per hari habis. Reset 00.00 UTC. Polling cek invoice tidak memakai kuota ini. | | `order_id` sama di sandbox dan live | Boleh. Keduanya terpisah. | --- ## Kedaluwarsa, bayar telat, dan retry aman dengan Idempotency-Key URL: https://temanqris.com/docs#use-case-retry **Untuk siapa:** semua integrasi gateway, dan integrasi manual yang perlu tahan terhadap timeout jaringan. **Konsep** - **Idempotency-Key** menjawab pertanyaan "request ini sudah pernah diproses atau belum". Key dihitung bersama akun dan `order_id`, disimpan sebagai hash, dan mengingat isi request pertama (`amount`, `fee_amount`, `order_id`, `title`, `description`, `webhook_url`, `callback_url`). - **`order_id`** hanya bisa punya satu pembayaran gateway, apa pun hasilnya. Untuk mencoba lagi setelah gagal atau kedaluwarsa, pakai `order_id` baru. - **QR gateway** berlaku 1 jam secara bawaan. Setelah itu halaman bayar menampilkan kedaluwarsa, tapi pembayaran yang dilakukan dalam **masa tenggang** (60 menit secara bawaan setelah kedaluwarsa) tetap dicocokkan dan dikreditkan. Jadi order baru final setelah `expiresAt` + 60 menit secara bawaan. **Alur retry yang aman** 1. Sebelum memanggil API, simpan di database: `order_id`, nomor percobaan (misalnya `1`), dan `Idempotency-Key` (misalnya `INV-2026-0001-1`). 2. Kirim request. Kalau ada timeout, error jaringan, `5xx`, atau `409 gateway_creation_in_progress`, ulangi **request yang sama** dengan **key yang sama** memakai backoff (1 detik, 2 detik, 4 detik, maksimal 5 kali). 3. `200` dengan `existing: true` berarti invoice sudah ada. Pakai datanya seperti response `201`. 4. Kalau invoice kedaluwarsa dan pelanggan ingin mencoba lagi, tunggu sampai `payable_until` lewat kalau memungkinkan, lalu buat percobaan baru: `order_id` `INV-2026-0001-R2`, key `INV-2026-0001-R2-1`. 5. Tandai order Anda "kedaluwarsa final" hanya setelah `payable_until` lewat tanpa webhook (`expiresAt` + 60 menit secara bawaan). Sebelum itu, statusnya "menunggu, mungkin bayar telat". **Cara membaca status setiap respons** | Respons | Arti | Tindakan | | --- | --- | --- | | `201` | Invoice baru dibuat. | Simpan dan tampilkan. | | `200` + `existing: true` | Invoice yang sama dari request sebelumnya. | Pakai, jangan buat lagi. | | Timeout, error jaringan, `500`, `503 webhook_dns_unavailable` | Hasil tidak pasti. | Ulangi dengan key dan isi yang sama. | | `409 gateway_creation_in_progress` | Masih diproses. | Tunggu, ulangi yang sama. | | `409 idempotency_key_in_use` | Request paralel dengan key yang sama. | Ulangi yang sama setelah jeda singkat. | | `422 idempotency_key_reused` | Isi berbeda untuk key yang sama. Ini bug di kode Anda. | Jangan retry otomatis. Perbaiki pembuatan key atau isi request. | | `409 gateway_order_id_used` | `order_id` sudah terpakai. | Kalau invoice lama masih berlaku, pakai itu (cek `GET /api/qris/orders/:orderId`). Kalau sudah gagal atau kedaluwarsa, pakai `order_id` baru. | | `503 gateway_amount_busy` | Kode unik untuk nominal ini habis sementara. | Ulangi beberapa menit kemudian. | | `503 gateway_amount_reservation_lost` | Percobaan ini gagal disiapkan. | Buat percobaan baru dengan `order_id` baru. | | `503 provider_timeout` | Penyedia lambat, tidak ada QR yang dibuat. | Buat percobaan baru dengan `order_id` baru. | | `400` / `403` lain | Input atau akun bermasalah. | Jangan retry. Perbaiki penyebabnya. | **Retry wrapper (Node.js)** ```js const RETRYABLE = new Set(['gateway_creation_in_progress', 'idempotency_key_in_use', 'webhook_dns_unavailable']); // Percobaan ini sudah gagal di server. Key yang sama hanya akan dijawab 409, jadi buat order_id baru. const NEW_ATTEMPT = new Set(['provider_timeout', 'gateway_amount_reservation_lost']); async function createGatewayPayment(order, attempt) { const key = `${order.id}-${attempt}`; // simpan ke DB sebelum fungsi ini dipanggil const body = JSON.stringify({ order_id: attempt === 1 ? order.id : `${order.id}-R${attempt}`, amount: order.subtotal, webhook_url: 'https://tokokamu.id/webhooks/temanqris' }); for (let i = 0; i < 5; i += 1) { let res; try { res = await fetch('https://temanqris.com/api/payment-gateway/payments', { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY, 'Idempotency-Key': key, 'Content-Type': 'application/json' }, body, // isi yang sama di setiap percobaan signal: AbortSignal.timeout(25000) // TemanQRIS menjawab dalam 20 detik }); } catch (networkError) { await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); continue; } const data = await res.json().catch(() => ({})); if (res.ok) return data; // 201 baru atau 200 existing if ((res.status >= 500 && !NEW_ATTEMPT.has(data.code)) || RETRYABLE.has(data.code)) { await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); continue; } const error = new Error(data.error || `HTTP ${res.status}`); error.code = data.code; throw error; // 4xx lain: jangan retry } throw new Error('TemanQRIS belum bisa dihubungi, coba lagi nanti'); } ``` **Retry wrapper (PHP)** ```php $attempt === 1 ? $order['id'] : $order['id'] . '-R' . $attempt, 'amount' => (int) $order['subtotal'], 'webhook_url' => 'https://tokokamu.id/webhooks/temanqris', ]); for ($i = 0; $i < 5; $i++) { $ch = curl_init('https://temanqris.com/api/payment-gateway/payments'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => [ 'X-API-Key: ' . getenv('TEMANQRIS_API_KEY'), 'Idempotency-Key: ' . $key, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $body, ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($raw === false) { // error jaringan sleep(2 ** $i); continue; } $data = json_decode($raw, true) ?: []; if ($status === 200 || $status === 201) { return $data; } if ($status >= 500 || in_array($data['code'] ?? '', $retryable, true)) { sleep(2 ** $i); continue; } throw new RuntimeException(($data['code'] ?? 'http_' . $status) . ': ' . ($data['error'] ?? '')); } throw new RuntimeException('TemanQRIS belum bisa dihubungi, coba lagi nanti'); } ``` **Menangani bayar telat di webhook (Node.js)** ```js const crypto = require('crypto'); function verifySignature(rawBody, header, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); if (typeof header !== 'string' || header.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req.body, req.get('X-TemanQRIS-Signature'), process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); if (req.get('X-TemanQRIS-Mode') !== 'live' || event.data.mode !== 'live') return res.sendStatus(400); if (event.event !== 'payment.confirmed' || !(await db.webhookEvents.insertIgnore(event.idempotency_key))) { return res.sendStatus(204); } const attempt = await db.attempts.findByGatewayOrderId(event.data.order_id); // INV-2026-0001 atau INV-2026-0001-R2 const late = new Date(event.data.paid_at) > new Date(attempt.expires_at); const order = await db.orders.find(attempt.order_id); if (order.status === 'paid') { // Pelanggan membayar dua percobaan. Dana kedua sudah masuk wallet Anda. await db.refundQueue.insert({ order: order.id, attempt: event.data.order_id, amount: event.data.total_amount }); } else { await db.orders.update(order.id, { status: 'paid', paid_late: late }); } res.sendStatus(204); }); ``` **Menangani bayar telat di webhook (PHP)** ```php PDO::ERRMODE_EXCEPTION]); $pdo->beginTransaction(); $ins = $pdo->prepare('INSERT IGNORE INTO webhook_events (idempotency_key) VALUES (?)'); $ins->execute([$event['idempotency_key']]); if ($ins->rowCount() === 1) { $q = $pdo->prepare('SELECT a.order_id, a.expires_at, o.status FROM attempts a JOIN orders o ON o.id = a.order_id WHERE a.gateway_order_id = ? FOR UPDATE'); $q->execute([$event['data']['order_id']]); $row = $q->fetch(PDO::FETCH_ASSOC); $late = new DateTime($event['data']['paid_at']) > new DateTime($row['expires_at']); if ($row['status'] === 'paid') { $pdo->prepare('INSERT INTO refund_queue (order_id, gateway_order_id, amount) VALUES (?, ?, ?)') ->execute([$row['order_id'], $event['data']['order_id'], $event['data']['total_amount']]); } else { $pdo->prepare("UPDATE orders SET status = 'paid', paid_late = ? WHERE id = ?") ->execute([$late ? 1 : 0, $row['order_id']]); } } $pdo->commit(); http_response_code(204); ``` **Kasus khusus** | Kejadian | Yang perlu Anda lakukan | | --- | --- | | Webhook datang setelah order Anda dibatalkan otomatis | Dana sudah masuk ke wallet Anda. Kirim barang atau kembalikan dana lewat support. Jangan abaikan. | | Mau mencegah bayar telat sama sekali | Batalkan order gateway dengan `POST /api/qris/orders/:orderId/cancel`. Pembayaran yang tetap masuk setelah itu tidak dikreditkan otomatis dan ditinjau admin. | | Pembeli membatalkan pesanan di sistem Anda sebelum membayar | Panggil `POST /api/qris/orders/:orderId/cancel` saat itu juga, termasuk kalau QR sudah kedaluwarsa tapi masa tenggang belum lewat. Beri tahu pembeli supaya tidak membayar. Halaman bayar langsung menampilkan dibatalkan. Kalau pembeli tetap membayar, dana tidak masuk wallet Anda dan ditinjau admin. Pembeli menghubungi support dengan bukti bayar untuk refund. | | `payment.confirmed` datang untuk order yang sudah Anda batalkan | Bisa terjadi kalau pembayaran masuk sebelum pembatalan Anda tercatat, atau admin mengkreditkan pembayaran yang ditinjau. Dana sudah ada di wallet Anda. Proses pesanannya, atau minta refund lewat support. Selalu proses `payment.confirmed`, apa pun status order di sistem Anda. | | Pembeli batal tapi order tidak Anda batalkan di TemanQRIS | Order tetap bisa dibayar sampai `payable_until`, dan pembayaran itu dikreditkan otomatis. Batalkan lewat API kalau pesanan memang tidak akan diproses. | | Pelanggan membayar percobaan lama dan baru | Keduanya dikreditkan. Kembalikan salah satunya lewat support. Untuk mengurangi risiko ini, buat percobaan baru setelah `payable_until` percobaan lama lewat, atau batalkan percobaan lama dulu. | | Mengulang dengan key baru tapi `order_id` lama | Ditolak `409 gateway_order_id_used`. Key baru tidak membuka `order_id` lama. | | Key sama untuk `order_id` berbeda | Membuat pembayaran terpisah, karena key dihitung bersama `order_id`. Buat key yang selalu memuat `order_id`. | | Jalur manual: request `payment-link` timeout | Tidak ada Idempotency-Key. Ulangi request yang sama. Kalau dapat `400 Order ID already exists`, link sudah dibuat: ambil dengan `GET /api/qris/orders/:orderId`. | | Jalur manual: link kedaluwarsa | Panggil `POST /api/qris/generate` dengan `order_id` dan nominal yang sama untuk mengaktifkan link yang sama lagi. | --- ## Memasang widget pembayaran di website URL: https://temanqris.com/docs#use-case-widget **Untuk siapa:** landing page, halaman donasi, katalog sederhana, atau website tanpa backend checkout. Satu baris script, tidak perlu API key. Widget memakai jalur manual (QRIS Anda sendiri). **Alur** 1. Ambil Merchant ID di [Dashboard > Widget](/dashboard/widget). 2. Pasang script di halaman. Atur `data-amount` untuk harga tetap, atau biarkan kosong supaya pelanggan mengisi nominal. 3. Pelanggan menekan tombol. Widget memanggil `POST /api/widget/generate`, membuat link dengan `order_id` berawalan `ORD-W`, lalu menampilkan QR (berlaku 60 menit). 4. Pelanggan membayar, lalu menekan tombol konfirmasi di widget. Widget memanggil `POST /api/widget/confirm/:orderId`. 5. Status menjadi `awaiting_confirmation`. Kalau `data-webhook` diisi dan webhook secret sudah dibuat, server Anda menerima `payment.awaiting_confirmation`. 6. Kalau `data-callback` diisi, browser pelanggan diarahkan ke URL itu dengan `?order_id=...&amount=...&status=confirmed`. Parameter ini dibuat browser, jadi **bukan bukti bayar**. 7. Anda mengecek mutasi, lalu verifikasi lewat dashboard atau `POST /api/qris/orders/:orderId/verify`. Webhook `payment.confirmed` terkirim. **Kode yang dipasang** ```html ``` **Yang dikirim widget ke TemanQRIS** ```http POST /api/widget/generate Content-Type: application/json { "merchant_id": "abc123xyz", "amount": 50000, "description": "Kelas Online Batch 7", "callback_url": "https://kelaskamu.id/terima-kasih", "webhook_url": "https://kelaskamu.id/webhooks/temanqris" } ``` ```json { "success": true, "order_id": "ORD-W5E1A9C03B7D2F4E6", "link_code": "W8F3A1C7E92B4D605", "amount": 50000, "merchant_name": "KELAS KAMU", "qr_image": "data:image/png;base64,iVBOR...", "qris_string": "00020101021226...", "expires_at": "2026-10-17T04:00:00.000Z", "description": "Kelas Online Batch 7" } ``` **Halaman terima kasih (Node.js)** ```js app.get('/terima-kasih', async (req, res) => { const orderId = String(req.query.order_id || '').slice(0, 30); const r = await fetch(`https://temanqris.com/api/qris/orders/${encodeURIComponent(orderId)}`, { headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); const data = await r.json().catch(() => ({})); const status = data.order ? data.order.status : 'unknown'; res.render('terima-kasih', { message: status === 'paid' ? 'Pembayaran diterima.' : 'Pembayaran sedang kami periksa.' }); }); ``` **Webhook (Node.js)** ```js const express = require('express'); const crypto = require('crypto'); const app = express(); const PRICE = 50000; // harga yang Anda harapkan function verifySignature(rawBody, header, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); if (typeof header !== 'string' || header.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req.body, req.get('X-TemanQRIS-Signature'), process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); if (req.get('X-TemanQRIS-Mode') !== 'live' || event.data.mode !== 'live') return res.sendStatus(400); if (!(await db.webhookEvents.insertIgnore(event.idempotency_key))) return res.sendStatus(204); if (event.data.amount !== PRICE) { await db.anomalies.insert({ event }); // nominal tidak sesuai harga return res.sendStatus(204); } if (event.event === 'payment.awaiting_confirmation') { await notifyAdmin(`Cek mutasi Rp${event.data.amount} untuk ${event.data.order_id}`); } if (event.event === 'payment.confirmed') { await grantAccess(event.data.order_id); } res.sendStatus(204); }); ``` **Webhook (PHP)** ```php PDO::ERRMODE_EXCEPTION]); $ins = $pdo->prepare('INSERT IGNORE INTO webhook_events (idempotency_key) VALUES (?)'); $ins->execute([$event['idempotency_key']]); if ($ins->rowCount() === 0) { http_response_code(204); exit; } if ((int) $event['data']['amount'] !== PRICE) { error_log('Nominal widget tidak sesuai: ' . $raw); } elseif ($event['event'] === 'payment.confirmed') { $pdo->prepare('INSERT INTO enrollments (order_id, paid_at) VALUES (?, NOW())') ->execute([$event['data']['order_id']]); } http_response_code(204); ``` **Kasus khusus** | Kejadian | Yang perlu Anda lakukan | | --- | --- | | Pelanggan membuka `data-callback` tanpa bayar | Parameter `status=confirmed` dibuat browser. Jangan beri akses dari halaman ini. Tunggu `payment.confirmed`. | | Merchant ID terlihat di HTML | Memang publik dan bukan rahasia. Orang lain bisa membuat QR atas nama toko Anda, tapi dana tetap masuk ke QRIS Anda. Selalu cek nominal di webhook. | | Nominal tidak sesuai harga | Siapa pun bisa memanggil widget API dengan nominal lain. Bandingkan `data.amount` dengan harga Anda. | | Pembayaran widget di daftar link | `payment_mode` bernilai `unknown` dan `order_id` berawalan `ORD-W`. | | QR kedaluwarsa | 60 menit setelah dibuat. Pelanggan cukup menekan tombol lagi untuk QR baru. | | Kuota | Setiap QR widget manual dihitung terhadap kuota harian tier Anda. QR widget mode gateway tidak memakai kuota harian. Keduanya dibatasi 60 per menit per IP. | | Butuh konfirmasi otomatis | `widget.js` selalu manual. Untuk otomatis, panggil [Widget API](#widget-api) dengan `payment_mode: "gateway"` dari halaman Anda sendiri, atau pakai gateway dari backend. | --- ## Top up saldo dan tagihan SaaS URL: https://temanqris.com/docs#use-case-saas **Untuk siapa:** aplikasi dengan saldo atau langganan (game, SaaS, layanan digital) yang menambah saldo pengguna otomatis. **Alur** 1. Pengguna memilih nominal top up, misalnya Rp50.000. 2. Backend membuat baris `topups` (status `pending`) dengan `order_id` `TOPUP-` dan key `TOPUP--1`. 3. Kalau ingin pengguna menanggung biaya layanan, isi `fee_amount` sama dengan biaya layanan gateway: 0,8% dari `amount`, dibulatkan ke atas. Dengan begitu `net_amount` sama dengan nominal top up. 4. `POST /api/payment-gateway/payments`, tampilkan QR di aplikasi. 5. Saat `payment.confirmed`, tambah saldo pengguna **dalam satu transaksi** dengan pencatatan `idempotency_key`. 6. Aplikasi memeriksa status top up dari database Anda (bukan dari TemanQRIS) setiap beberapa detik. **Request** ```bash curl -X POST https://temanqris.com/api/payment-gateway/payments \ -H "X-API-Key: YOUR_API_KEY" \ -H "Idempotency-Key: TOPUP-88120-1" \ -H "Content-Type: application/json" \ -d '{ "order_id": "TOPUP-88120", "amount": 50000, "fee_amount": 400, "title": "Top up saldo", "webhook_url": "https://api.appkamu.id/webhooks/temanqris" }' ``` **Response `201 Created` (ringkas)** ```json { "success": true, "existing": false, "payment_mode": "gateway", "payment_attempt_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", "payment_link": { "linkCode": "Qm7tR2xP9aKd", "orderId": "TOPUP-88120", "amount": 50000, "feeAmount": 400, "provider_fee_amount": 400, "net_amount": 50000, "uniqueCode": 642, "totalAmount": 51042, "expiresAt": "2026-10-17T03:15:00.000Z", "payable_until": "2026-10-17T04:15:00.000Z", "url": "/p/Qm7tR2xP9aKd", "qr_image": "data:image/png;base64,iVBOR..." }, "mode": "live" } ``` Pengguna membayar Rp51.042 (Rp50.000 + fee Rp400 + kode unik 642). Wallet Anda bertambah Rp50.000, karena biaya layanan 0,8% dari Rp50.000 adalah Rp400. **Webhook (Node.js)** ```js const crypto = require('crypto'); function verifySignature(rawBody, header, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); if (typeof header !== 'string' || header.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifySignature(req.body, req.get('X-TemanQRIS-Signature'), process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); if (req.get('X-TemanQRIS-Mode') !== 'live' || event.data.mode !== 'live') return res.sendStatus(400); if (event.event !== 'payment.confirmed') return res.sendStatus(204); await db.transaction(async (tx) => { if (!(await tx.webhookEvents.insertIgnore(event.idempotency_key))) return; const topup = await tx.topups.findForUpdate(event.data.order_id); if (!topup || topup.status === 'paid') return; await tx.topups.update(topup.id, { status: 'paid' }); await tx.balances.increment(topup.user_id, topup.amount); // nominal dari DB Anda, bukan dari payload }); res.sendStatus(204); }); ``` **Webhook (PHP)** ```php PDO::ERRMODE_EXCEPTION]); $pdo->beginTransaction(); $ins = $pdo->prepare('INSERT IGNORE INTO webhook_events (idempotency_key) VALUES (?)'); $ins->execute([$event['idempotency_key']]); if ($ins->rowCount() === 1) { $q = $pdo->prepare("SELECT id, user_id, amount, status FROM topups WHERE order_id = ? FOR UPDATE"); $q->execute([$event['data']['order_id']]); $t = $q->fetch(PDO::FETCH_ASSOC); if ($t && $t['status'] !== 'paid') { $pdo->prepare("UPDATE topups SET status = 'paid' WHERE id = ?")->execute([$t['id']]); $pdo->prepare('UPDATE balances SET amount = amount + ? WHERE user_id = ?')->execute([$t['amount'], $t['user_id']]); } } $pdo->commit(); } http_response_code(204); ``` **Kasus khusus** | Kejadian | Yang perlu Anda lakukan | | --- | --- | | Top up kecil ditolak `amount_below_provider_fee` | `amount + fee_amount` harus lebih dari biaya layanan. Tetapkan minimal top up, misalnya Rp10.000. | | Top up di atas Rp1.000.000 | Ditolak `amount_too_large`. Batasi nominal di aplikasi. | | Banyak top up bersamaan | Maksimal 50 invoice terbuka per merchant (bawaan). Sebelum membuat invoice baru, pakai ulang invoice yang masih berlaku untuk pengguna yang sama. | | Saldo pengguna ditambah dua kali | Tidak akan terjadi kalau pencatatan `idempotency_key` dan penambahan saldo ada di satu transaksi. | | Polling dari aplikasi | Baca database Anda. Kalau perlu cek ke TemanQRIS, lakukan dari backend dengan `GET /api/qris/orders/:orderId`. | --- ## Banyak outlet dalam satu akun URL: https://temanqris.com/docs#use-case-outlets **Untuk siapa:** pemilik beberapa cabang atau beberapa rekening penerima. Butuh Premium (sampai 100 QRIS statis secara bawaan, batasnya tampil di halaman harga). **Alur** 1. Upload QRIS statis tiap outlet dengan `POST /api/qris/upload`. 2. Panggil `GET /api/qris/my-qris`. Simpan `id` dari `qris_list` untuk setiap outlet di sistem Anda. 3. Saat membuat QR, kirim `qris_id` (untuk `generate`) atau `qris_data_id` (untuk `payment-link`). Tanpa itu, QRIS yang paling baru diperbarui yang dipakai. 4. Pakai awalan `order_id` per outlet, misalnya `BDG-0001`, `JKT-0001`, supaya laporan mudah dipisah. **Request** ```bash curl -X POST https://temanqris.com/api/qris/generate \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 72000, "qris_id": 124, "order_id": "BDG-0001" }' ``` Response sama dengan [Generate QRIS](#generate). Webhook dan verifikasi sama dengan [Kasir dan UMKM](#use-case-cashier). **Kasus khusus** | Kejadian | Yang perlu Anda lakukan | | --- | --- | | `404 No QRIS found` | `qris_id` salah atau QRIS itu sudah dihapus. Cek ulang `my-qris`. | | `400 Merchant limit reached` saat upload | Batas QRIS paket tercapai. Hapus QRIS yang tidak dipakai di dashboard. | | Akun Free | Hanya satu QRIS. Upload ulang akan mengganti QRIS lama. | --- ## Rekonsiliasi harian URL: https://temanqris.com/docs#use-case-reconcile **Untuk siapa:** tim keuangan yang ingin memastikan semua order lunas tercatat, termasuk yang webhooknya gagal. **Alur** 1. Setiap pagi (setelah 07.00 WIB), jalankan job yang membaca `GET /api/qris/orders?status=paid&limit=100&offset=0`, lalu naikkan `offset` sampai semua halaman terbaca. 2. Untuk setiap order, cocokkan `order_id`, `total_amount`, dan `paid_at` dengan database Anda. 3. Order yang `paid` di TemanQRIS tapi belum `paid` di sistem Anda: proses seperti webhook yang terlambat. 4. Untuk gateway, jumlahkan `net_amount` per hari dan bandingkan dengan saldo wallet. Dana pembayaran hari ini baru bisa ditarik mulai 00.00 WIB besok. 5. Order yang masih `pending` dengan `is_expired: true` sudah kedaluwarsa. **Request** ```bash curl "https://temanqris.com/api/qris/orders?status=paid&limit=100&offset=0" \ -H "X-API-Key: YOUR_API_KEY" ``` **Job (Node.js)** ```js async function reconcile() { for (let offset = 0; ; offset += 100) { const r = await fetch(`https://temanqris.com/api/qris/orders?status=paid&limit=100&offset=${offset}`, { headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const { orders, pagination } = await r.json(); for (const o of orders) { const mine = await db.orders.find(o.order_id); if (mine && mine.status !== 'paid') await db.orders.markPaidFromReconcile(o); } if (offset + orders.length >= pagination.total) break; } } ``` **Kasus khusus** | Kejadian | Yang perlu Anda lakukan | | --- | --- | | Order yang menunggu verifikasi | Ambil dengan `GET /api/qris/orders?status=awaiting_confirmation`, lalu cek mutasi untuk setiap order. Nilai `status` yang tidak dikenal ditolak `400`. | | Data banyak | `limit` maksimal 100 per halaman. Jalankan di luar jam ramai. | | Butuh `net_amount` per link | `GET /api/qris/payment-links` juga memuat `provider_fee_amount` dan `net_amount` untuk link gateway. | --- ## Alur cURL lengkap URL: https://temanqris.com/docs#use-case-curl **Untuk siapa:** developer yang ingin mencoba seluruh jalur manual dari terminal sebelum menulis kode backend. **Alur** 1. Cek apakah QRIS statis sudah ada dengan `GET /api/qris/my-qris`. 2. Kalau `has_qris` bernilai `false`, upload sekali lewat `POST /api/qris/upload`. 3. Buat link untuk satu order dengan `POST /api/qris/payment-link`. 4. Bagikan `https://temanqris.com` + `payment_link.url` ke pelanggan, atau arahkan dari halaman checkout. 5. Pelanggan membayar dan menekan Sudah Bayar. Anda menerima webhook `payment.awaiting_confirmation`. 6. Cek status order dari backend dengan `GET /api/qris/orders/:orderId`. 7. Setelah dana terlihat di rekening atau e-wallet, verifikasi dengan `POST /api/qris/orders/:orderId/verify`. Webhook `payment.confirmed` terkirim. 8. Pantau sisa kuota dengan `GET /api/qris/usage`. **Semua perintah** ```bash # 1. Cek apakah QRIS statis sudah ada curl https://temanqris.com/api/qris/my-qris \ -H "X-API-Key: YOUR_API_KEY" # 2. Upload QRIS statis sekali, hanya kalau has_qris bernilai false curl -X POST https://temanqris.com/api/qris/upload \ -H "X-API-Key: YOUR_API_KEY" \ -F "qris_image=@/path/to/qris.png" # 3. Buat payment link untuk satu order curl -X POST https://temanqris.com/api/qris/payment-link \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 125000, "description": "Checkout order INV-2026-0001", "order_id": "INV-2026-0001", "webhook_url": "https://company.com/webhooks/temanqris", "callback_url": "https://company.com/orders/INV-2026-0001/thank-you" }' # 4. Bagikan URL dari response, misalnya https://temanqris.com/p/ABC12345XYZW # 5. Cek status order dari backend curl https://temanqris.com/api/qris/orders/INV-2026-0001 \ -H "X-API-Key: YOUR_API_KEY" # 6. Verifikasi setelah tim keuangan memastikan dana masuk curl -X POST https://temanqris.com/api/qris/orders/INV-2026-0001/verify \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "payer_name": "Budi Santoso", "payer_note": "Dana sudah masuk ke rekening merchant" }' # 7. Pantau kuota harian curl https://temanqris.com/api/qris/usage \ -H "X-API-Key: YOUR_API_KEY" ``` > **Catatan production.** Panggil verify hanya dari backend internal setelah dana benar-benar masuk. Jangan buat tombol publik yang langsung memanggil `/orders/:orderId/verify`. --- ## Upload QRIS Statis URL: https://temanqris.com/docs#upload `POST /api/qris/upload` (memakai kuota) Upload QRIS statis Anda sebagai template. **Cukup sekali** saat setup. Setelah itu langsung pakai `/generate` atau `/payment-link` tanpa upload ulang. Pakai [`/my-qris`](#my-qris) untuk mengecek apakah QRIS sudah ada. **Aturan** - Hanya QRIS **statis**. QRIS dinamis ditolak. - Gambar: JPG/JPEG, PNG, GIF, BMP, atau TIFF. WebP belum didukung. Ukuran maksimal 1,5 MB (bawaan), sisi terpanjang maksimal 6000 px, total maksimal 16 megapiksel. Field form: `qris_image`. - String: kirim JSON `{"qris_string": "000201..."}` kalau Anda sudah punya isi QR hasil decode. - Akun Free menyimpan 1 QRIS. Upload ulang akan **mengganti** QRIS lama. Akun Premium menambah QRIS baru sampai batas paket (bawaan 100, tampil di halaman harga). **Opsi 1: upload gambar** ```bash curl -X POST https://temanqris.com/api/qris/upload \ -H "X-API-Key: YOUR_API_KEY" \ -F "qris_image=@/path/to/qris.png" ``` ```js // Node.js 18+ import { readFile } from 'node:fs/promises'; const form = new FormData(); form.append('qris_image', new Blob([await readFile('./qris.png')], { type: 'image/png' }), 'qris.png'); const res = await fetch('https://temanqris.com/api/qris/upload', { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY }, body: form }); console.log(res.status, await res.json()); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], CURLOPT_POSTFIELDS => ['qris_image' => new CURLFile('/path/to/qris.png', 'image/png', 'qris.png')], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python import os import requests with open("qris.png", "rb") as f: r = requests.post( "https://temanqris.com/api/qris/upload", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, files={"qris_image": ("qris.png", f, "image/png")}, timeout=30, ) print(r.status_code, r.json()) ``` **Opsi 2: upload string** ```bash curl -X POST https://temanqris.com/api/qris/upload \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "qris_string": "00020101021126670016COM.NOBUBANK.WWW..." }' ``` ```js const res = await fetch('https://temanqris.com/api/qris/upload', { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ qris_string: process.env.MY_STATIC_QRIS }) }); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['qris_string' => getenv('MY_STATIC_QRIS')]), ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python r = requests.post( "https://temanqris.com/api/qris/upload", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, json={"qris_string": os.environ["MY_STATIC_QRIS"]}, timeout=15, ) ``` **Response `200 OK`** ```json { "message": "QRIS uploaded successfully", "qris_string": "00020101021126...", "merchant": { "name": "TOKO EXAMPLE", "city": "JAKARTA" } } ``` **Error** | Status | Body | Penyebab | | --- | --- | --- | | 400 | `{"error":"QRIS string or image is required"}` | Tidak ada file maupun string. | | 400 | `{"error":"Could not decode QR code. Please try a clearer image or input manually."}` | Gambar tidak terbaca. Coba foto yang lebih jelas atau kirim string. | | 400 | `{"error":"Only image files are allowed (jpeg, jpg, png, gif, bmp, tiff)","code":400}` | Tipe file tidak didukung, misalnya WebP. | | 400 | `{"error":"File too large. Max size is 1.5MB"}` | File terlalu besar. | | 400 | `{"error":"Image dimensions are unsupported or too large"}` | Lebih dari 6000 px atau 16 megapiksel. | | 400 | `{"error":"Invalid QRIS format","details":["..."]}` | Isi QR bukan QRIS yang valid. | | 400 | `{"error":"Please upload a static QRIS code. Dynamic QRIS codes cannot be used as templates."}` | QRIS yang dikirim dinamis. | | 400 | `{"error":"Merchant limit reached","message":"Your tier allows 100 merchants. Delete an existing QRIS first."}` | Premium sudah mencapai batas QRIS paket (bawaan 100). | --- ## Cek QRIS URL: https://temanqris.com/docs#my-qris `GET /api/qris/my-qris` (tidak memakai kuota) Cek apakah Anda sudah pernah upload QRIS statis. Panggil sebelum `/upload` supaya tidak upload ulang tanpa perlu. `qris` adalah QRIS yang paling baru diperbarui, dan QRIS itulah yang dipakai `/generate` dan `/payment-link` kalau Anda tidak memilih `qris_id`. ```bash curl https://temanqris.com/api/qris/my-qris \ -H "X-API-Key: YOUR_API_KEY" ``` ```js const res = await fetch('https://temanqris.com/api/qris/my-qris', { headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); const { has_qris, qris_list } = await res.json(); ``` ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); if (!$data['has_qris']) { /* upload dulu */ } ``` ```python r = requests.get( "https://temanqris.com/api/qris/my-qris", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, timeout=15, ) data = r.json() ``` **Response (QRIS sudah ada) `200 OK`** ```json { "success": true, "has_qris": true, "qris": { "id": 123, "qris_string": "00020101021126...", "merchant_name": "TOKO EXAMPLE", "merchant_city": "JAKARTA", "created_at": "2026-10-01T03:30:00.000Z", "updated_at": "2026-10-01T03:30:00.000Z" }, "qris_list": [ { "id": 123, "qris_string": "00020101021126...", "merchant_name": "TOKO EXAMPLE", "merchant_city": "JAKARTA", "created_at": "2026-10-01T03:30:00.000Z", "updated_at": "2026-10-01T03:30:00.000Z" } ], "total": 1, "default_qris_id": 123 } ``` **Response (belum ada QRIS) `200 OK`** ```json { "success": true, "has_qris": false, "message": "No QRIS found. Please upload your static QRIS first (only needed once)." } ``` --- ## Generate QRIS Dinamis URL: https://temanqris.com/docs#generate `POST /api/qris/generate` (memakai kuota) Endpoint utama untuk membuat QRIS dinamis dengan nominal transaksi. Setiap panggilan juga membuat (atau memperbarui) payment link untuk `order_id` itu. **Request body** | Field | Tipe | Aturan | | --- | --- | --- | | `amount` | integer, wajib | Nominal dalam rupiah, bilangan bulat 1 sampai 100.000.000. Angka dalam string seperti `"50000"` juga diterima. | | `fee_type` | string, opsional | `"rupiah"` atau `"percent"`. Wajib kalau `fee_value` bukan 0. | | `fee_value` | number, opsional | Biaya tambahan yang dibayar pelanggan. `percent`: 0 sampai 100, maksimal 4 desimal, dibulatkan ke rupiah terdekat. `rupiah`: bilangan bulat, maksimal 10 kali `amount`. | | `qris_id` | integer, opsional | ID QRIS tertentu dari `my-qris`. Bawaan: QRIS yang paling baru diperbarui. | | `order_id` | string, opsional | ID order Anda, maksimal 30 karakter. Kalau kosong dibuat otomatis (`ORD-A` + 12 karakter). | | `webhook_url` | string, opsional | URL penerima webhook. Harus mengarah ke alamat publik. | | `callback_url` | string, opsional | URL http(s) tujuan pelanggan setelah menekan Sudah Bayar. | **Memakai `order_id` yang sama lagi** - Order masih belum dibayar (`pending`, `expired`, atau `cancelled`): link yang sama diperbarui dengan nominal dan QR baru, masa berlakunya diperpanjang, status kembali `pending`, dan response berisi `"updated": true`. URL yang sudah Anda bagikan tetap berlaku. `webhook_url` dan `callback_url` diganti dengan nilai yang Anda kirim (kosong kalau tidak dikirim). - Order sudah `paid` atau `awaiting_confirmation` dan request sama persis (nominal dan fee sama): order dikembalikan apa adanya dengan `"existing": true`. Status, `paid_at`, dan link tidak berubah, jadi retry aman. - Order sudah `paid` atau `awaiting_confirmation` tapi nominal atau fee berbeda: ditolak `409`. Pakai `order_id` baru. - `order_id` sudah dipakai pembayaran gateway: ditolak `409`. **Request** ```bash curl -X POST https://temanqris.com/api/qris/generate \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 50000, "fee_type": "rupiah", "fee_value": 1000, "order_id": "INV-2026-001", "webhook_url": "https://yoursite.com/webhook", "callback_url": "https://yoursite.com/thank-you" }' ``` ```js const res = await fetch('https://temanqris.com/api/qris/generate', { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: 50000, fee_type: 'rupiah', fee_value: 1000, order_id: 'INV-2026-001', webhook_url: 'https://yoursite.com/webhook', callback_url: 'https://yoursite.com/thank-you' }) }); const data = await res.json(); // dan tampilkan data.total_amount ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 50000, 'fee_type' => 'rupiah', 'fee_value' => 1000, 'order_id' => 'INV-2026-001', 'webhook_url' => 'https://yoursite.com/webhook', 'callback_url' => 'https://yoursite.com/thank-you', ]), ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); echo ''; ``` ```python r = requests.post( "https://temanqris.com/api/qris/generate", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, json={ "amount": 50000, "fee_type": "rupiah", "fee_value": 1000, "order_id": "INV-2026-001", "webhook_url": "https://yoursite.com/webhook", "callback_url": "https://yoursite.com/thank-you", }, timeout=15, ) data = r.json() ``` **Response `200 OK`** ```json { "success": true, "qris": "00020101021226...", "qr_image": "data:image/png;base64,...", "amount": 50000, "fee": { "type": "rupiah", "value": 1000 }, "fee_amount": 1000, "total_amount": 51000, "expires_at": "2026-10-18T03:00:00.000Z", "payment_link": { "id": 456, "link_code": "BTN3EV2XK7QP", "order_id": "INV-2026-001", "url": "/p/BTN3EV2XK7QP", "amount": 50000, "fee_type": "rupiah", "fee_value": 1000, "fee_amount": 1000, "total_amount": 51000, "merchant_name": "TOKO EXAMPLE", "webhook_url": "https://yoursite.com/webhook", "callback_url": "https://yoursite.com/thank-you", "expires_at": "2026-10-18T03:00:00.000Z" } } ``` **Response order sudah lunas `200 OK`** ```json { "success": true, "existing": true, "status": "paid", "message": "This order_id is already paid. The stored order is returned unchanged.", "qris": null, "qr_image": null, "amount": 50000, "fee": { "type": "rupiah", "value": 1000 }, "fee_amount": 1000, "total_amount": 51000, "expires_at": "2026-10-18T03:00:00.000Z", "payment_link": { "id": 456, "link_code": "BTN3EV2XK7QP", "order_id": "INV-2026-001", "url": "/p/BTN3EV2XK7QP", "amount": 50000, "total_amount": 51000, "status": "paid", "paid_at": "2026-10-17T03:12:40.000Z", "existing": true, "...": "..." } } ``` **Error `409`** ```json { "error": "Order ID sudah dibayar atau menunggu konfirmasi", "message": "Order ID 'INV-2026-001' sudah memiliki pembayaran yang nominalnya tidak bisa diubah. Gunakan Order ID baru untuk nominal berbeda.", "existing_link_code": "BTN3EV2XK7QP" } ``` | Field | Arti | | --- | --- | | `qr_image` | Gambar QR dalam Base64, bisa langsung dipakai di ``. | | `total_amount` | Yang dibayar pelanggan: `amount` + `fee_amount`. | | `expires_at` (luar) | Batas simpan riwayat QR di dashboard (diatur admin per tier). | | `payment_link.expires_at` | Batas link pembayaran: bawaan 24 jam (Free) atau 7 hari (Premium). | | `payment_link.updated` | Muncul `true` kalau link lama untuk `order_id` ini diperbarui. | | `existing` | Muncul `true` kalau order sudah `paid` atau `awaiting_confirmation` dan dikembalikan tanpa perubahan. Untuk order `paid`, `qris` dan `qr_image` bernilai `null` karena tidak ada lagi yang perlu dipindai. Order `awaiting_confirmation` tetap membawa keduanya. | | `payment_link` | Bisa `null` kalau pembuatan link gagal. QR tetap bisa dipakai. | **Error:** lihat [tabel error QRIS](#errors). Yang paling sering: `400` nominal atau fee tidak valid, `404 No QRIS found. Please upload your static QRIS first.`, `409` untuk `order_id` yang terkunci, dan `503` dengan `code: "webhook_dns_unavailable"` kalau DNS sedang bermasalah saat memeriksa `webhook_url` (kirim ulang request yang sama). --- ## Render QR URL: https://temanqris.com/docs#render `POST /api/qris/render` (tidak memakai kuota) Buat gambar QR dari string QRIS. Kirim salah satu: `qris_string` atau `qris_data_id` (ID QRIS tersimpan dari `/my-qris`). **Opsi 1: dari string** ```bash curl -X POST https://temanqris.com/api/qris/render \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "qris_string": "00020101021126..." }' ``` **Opsi 2: dari ID tersimpan** ```bash curl -X POST https://temanqris.com/api/qris/render \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "qris_data_id": 123 }' ``` ```js const res = await fetch('https://temanqris.com/api/qris/render', { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ qris_data_id: 123 }) }); const { qr_image } = await res.json(); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['qris_data_id' => 123]), ]); $qrImage = json_decode(curl_exec($ch), true)['qr_image'] ?? null; curl_close($ch); ``` ```python r = requests.post( "https://temanqris.com/api/qris/render", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, json={"qris_data_id": 123}, timeout=15, ) qr_image = r.json()["qr_image"] ``` **Response `200 OK`** ```json { "success": true, "qr_image": "data:image/png;base64,iVBOR..." } ``` **Error** | Status | Body | | --- | --- | | 400 | `{"error":"QRIS string or qris_data_id required"}` | | 400 | `{"error":"qris_string must be a string"}` atau `{"error":"qris_data_id must be a positive integer"}` | | 404 | `{"error":"QRIS data not found"}` | --- ## Payment Link dan Status Order URL: https://temanqris.com/docs#order-status Payment link adalah halaman bayar `https://temanqris.com/p/{link_code}` yang bisa dibagikan ke pelanggan. Link dari jalur manual dan gateway sama-sama tercatat sebagai order, jadi bisa dicek dengan endpoint order di bawah. Alur order: Buat QRIS, pelanggan bayar, webhook atau verifikasi, selesai. **Status order** | Status | Arti | | --- | --- | | `pending` | Menunggu pembayaran. | | `awaiting_confirmation` | Pelanggan menekan Sudah Bayar (manual dan widget). Menunggu Anda memverifikasi. | | `paid` | Pembayaran sah. Manual: sudah Anda verifikasi. Gateway: sudah dicocokkan otomatis. | | `expired` | Lewat batas waktu tanpa pembayaran. Status bisa masih terbaca `pending` sebentar sampai sistem menyapu link kedaluwarsa, jadi andalkan `is_expired`. Order gateway yang dibayar dalam masa tenggang bisa berubah dari `expired` menjadi `paid` sampai `payable_until` lewat. | | `cancelled` | Dibatalkan atau diarsipkan oleh merchant. | --- ## Buat Payment Link URL: https://temanqris.com/docs#payment-link-create `POST /api/qris/payment-link` (memakai kuota) Buat link pembayaran manual yang bisa dibagikan ke pelanggan lewat WhatsApp, email, atau media sosial. **Request body** | Field | Tipe | Aturan | | --- | --- | --- | | `amount` | integer, wajib | Bilangan bulat 1 sampai 100.000.000. | | `description` | string, opsional | Deskripsi, misalnya "Order #123". Maksimal 255 karakter. | | `order_id` | string, opsional | ID order Anda, maksimal 30 karakter, harus unik di akun Anda. Kalau kosong dibuat otomatis. | | `fee_type` | string, opsional | `"rupiah"` atau `"percent"`. | | `fee_value` | number, opsional | Biaya tambahan. `percent` maksimal 100. | | `qris_data_id` | integer, opsional | ID QRIS tertentu dari `my-qris`. | | `webhook_url` | string, opsional | HTTPS (di production), alamat publik. | | `callback_url` | string, opsional | URL http(s) tujuan pelanggan setelah menekan Sudah Bayar. | > **Link dipakai ulang per order.** Kalau `order_id` yang sama sudah punya link aktif dengan QRIS, nominal, dan fee yang sama, endpoint ini mengembalikan link itu (`"existing": true`) tanpa membuat link baru. Request tanpa `order_id` hanya memakai ulang link aktif yang juga tanpa `order_id`. Link milik `order_id` lain tidak pernah dipakai ulang. ```bash curl -X POST https://temanqris.com/api/qris/payment-link \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 75000, "description": "Pembayaran Order #12345", "order_id": "INV-2026-001", "webhook_url": "https://yoursite.com/webhook", "callback_url": "https://yoursite.com/thank-you" }' ``` ```js const res = await fetch('https://temanqris.com/api/qris/payment-link', { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: 75000, description: 'Pembayaran Order #12345', order_id: 'INV-2026-001', webhook_url: 'https://yoursite.com/webhook', callback_url: 'https://yoursite.com/thank-you' }) }); const data = await res.json(); // data.existing === true: link aktif untuk order ini dipakai ulang const shareUrl = 'https://temanqris.com' + data.payment_link.url; ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 75000, 'description' => 'Pembayaran Order #12345', 'order_id' => 'INV-2026-001', 'webhook_url' => 'https://yoursite.com/webhook', 'callback_url' => 'https://yoursite.com/thank-you', ]), ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); $shareUrl = 'https://temanqris.com' . $data['payment_link']['url']; ``` ```python r = requests.post( "https://temanqris.com/api/qris/payment-link", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, json={ "amount": 75000, "description": "Pembayaran Order #12345", "order_id": "INV-2026-001", "webhook_url": "https://yoursite.com/webhook", "callback_url": "https://yoursite.com/thank-you", }, timeout=15, ) link = r.json()["payment_link"] share_url = "https://temanqris.com" + link["url"] ``` **Response `200 OK`** ```json { "success": true, "payment_link": { "id": 456, "link_code": "ABC12345XYZW", "order_id": "INV-2026-001", "url": "/p/ABC12345XYZW", "amount": 75000, "fee_type": "none", "fee_value": 0, "fee_amount": 0, "total_amount": 75000, "description": "Pembayaran Order #12345", "merchant_name": "TOKO EXAMPLE", "webhook_url": "https://yoursite.com/webhook", "callback_url": "https://yoursite.com/thank-you", "expires_at": "2026-10-18T07:00:00.000Z", "qr_image": "data:image/png;base64,iVBOR..." } } ``` Kalau link aktif untuk `order_id` itu dipakai ulang, response juga berisi `"existing": true` dan `message` yang menjelaskannya. > **Tips.** Link lengkap: `https://temanqris.com/p/ABC12345XYZW`. Bagikan ke pelanggan via WhatsApp, email, dan lainnya. Link berlaku 24 jam (Free) atau 7 hari (Premium). **Error** | Status | Body | | --- | --- | | 400 | `{"error":"Order ID already exists","message":"Order ID 'INV-2026-001' is already used. Please use a unique order_id.","existing_link_code":"ABC12345XYZW"}` | | 400 | `{"error":"No active QRIS found. Please upload a static QRIS first."}` | | 400 | `{"error":"Webhook URL must use HTTPS"}` atau `{"error":"Invalid webhook URL format"}` | | 400 | `{"error":"Amount is required and must be a whole number between 1 and 100000000"}` | --- ## Daftar Payment Link URL: https://temanqris.com/docs#payment-links `GET /api/qris/payment-links` (tidak memakai kuota) Ambil daftar payment link Anda, terbaru lebih dulu. | Query | Bawaan | Arti | | --- | --- | --- | | `page` | 1 | Halaman. | | `limit` | 10 | Jumlah per halaman, maksimal 1000. | | `search` | kosong | Cari di `link_code`, `order_id`, atau nama merchant (maksimal 100 karakter). | ```bash curl "https://temanqris.com/api/qris/payment-links?page=1&limit=20" \ -H "X-API-Key: YOUR_API_KEY" ``` ```js const res = await fetch('https://temanqris.com/api/qris/payment-links?page=1&limit=20', { headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); const { payment_links, pagination } = await res.json(); ``` ```php 1, 'limit' => 20])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python r = requests.get( "https://temanqris.com/api/qris/payment-links", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, params={"page": 1, "limit": 20}, timeout=15, ) data = r.json() ``` **Response `200 OK`** ```json { "payment_links": [ { "id": 456, "order_id": "INV-2026-001", "link_code": "ABC12345XYZW", "url": "/p/ABC12345XYZW", "amount": 75000, "fee_type": "none", "fee_value": 0, "fee_amount": 0, "total_amount": 75000, "description": "Order #12345", "merchant_name": "TOKO EXAMPLE", "payment_mode": "manual", "is_active": 1, "status": "pending", "view_count": 5, "expires_at": "2026-10-18T07:00:00.000Z", "created_at": "2026-10-17T07:00:00.000Z", "is_expired": false }, { "id": 457, "order_id": "INV-2026-0001", "link_code": "aZ3kQ9mP2xLw", "url": "/p/aZ3kQ9mP2xLw", "amount": 150000, "fee_type": "rupiah", "fee_value": 0, "fee_amount": 321, "total_amount": 150321, "provider_fee_amount": 1200, "net_amount": 148800, "description": "2 item, Toko Kopi Senja", "merchant_name": null, "payment_mode": "gateway", "is_active": 1, "status": "paid", "view_count": 2, "expires_at": "2026-10-17T03:15:00.000Z", "created_at": "2026-10-17T03:00:00.000Z", "is_expired": false } ], "pagination": { "page": 1, "limit": 20, "total": 25, "totalPages": 2 } } ``` - `payment_mode`: `manual`, `gateway`, atau `unknown` (link dari widget atau data lama). - Untuk link gateway, `fee_amount` di daftar ini adalah selisih `total_amount - amount` (biaya tambahan ditambah kode unik). Angka yang dikreditkan ke wallet ada di `net_amount`. - `status` sudah memperhitungkan kedaluwarsa: link yang belum dibayar dan lewat `expires_at` tampil `expired`. --- ## Cek Status Order URL: https://temanqris.com/docs#check-order `GET /api/qris/orders/:orderId` (tidak memakai kuota) Cek status satu order, manual maupun gateway. Ini juga cara membaca status pembayaran gateway live (tidak ada endpoint baca terpisah untuk gateway live). ```bash curl https://temanqris.com/api/qris/orders/ORD-ABC123 \ -H "X-API-Key: YOUR_API_KEY" ``` ```js const res = await fetch(`https://temanqris.com/api/qris/orders/${encodeURIComponent(orderId)}`, { headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); if (res.status === 404) { /* order tidak ada di akun ini */ } const { order } = await res.json(); ``` ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); $isPaid = $data['order']['is_paid'] ?? false; ``` ```python from urllib.parse import quote r = requests.get( f"https://temanqris.com/api/qris/orders/{quote(order_id, safe='')}", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, timeout=15, ) order = r.json().get("order") ``` **Response order manual `200 OK`** ```json { "success": true, "order": { "order_id": "ORD-ABC123", "title": "Pembayaran Invoice #001", "description": "Pembayaran QRIS", "amount": 50000, "fee_type": "rupiah", "fee_value": 1000, "total_amount": 51000, "status": "paid", "payment_method": "qris", "created_at": "2026-10-17T03:30:00.000Z", "paid_at": "2026-10-17T03:35:00.000Z", "expires_at": "2026-10-18T03:30:00.000Z", "is_paid": true, "is_expired": false } } ``` **Response order gateway `200 OK`** ```json { "success": true, "order": { "order_id": "INV-2026-0001", "title": "Pesanan INV-2026-0001", "description": "2 item, Toko Kopi Senja", "amount": 150000, "fee_type": "rupiah", "fee_value": 0, "total_amount": 150321, "provider_fee_amount": 1200, "net_amount": 148800, "status": "paid", "payment_method": "gateway", "created_at": "2026-10-17T03:00:00.000Z", "paid_at": "2026-10-17T03:08:12.000Z", "expires_at": "2026-10-17T03:15:00.000Z", "payable_until": null, "is_paid": true, "is_expired": false } } ``` **Error `404`** ```json { "success": false, "error": "Order not found" } ``` - `payment_method`: `qris` (manual API dan dashboard), `widget`, atau `gateway`. - `is_expired` adalah tanda kedaluwarsa yang bisa diandalkan. `status` bisa masih `pending` sebentar setelah lewat `expires_at`. - `paid_at` setelah `expires_at` pada order gateway berarti pelanggan membayar dalam masa tenggang. - `payable_until` (gateway saja): batas terakhir pembayaran yang masih dicocokkan dan dikreditkan otomatis, yaitu `expires_at` ditambah masa tenggang. Bernilai `null` kalau order sudah lunas, dibatalkan, atau tidak bisa lagi dikreditkan otomatis, dan selalu `null` untuk order manual. Biarkan order Anda terbuka sampai waktu ini. --- ## Daftar Order URL: https://temanqris.com/docs#list-orders `GET /api/qris/orders` (tidak memakai kuota) | Query | Bawaan | Arti | | --- | --- | --- | | `status` | semua | `pending`, `awaiting_confirmation`, `paid`, `expired`, atau `cancelled`. Nilai lain ditolak `400`. | | `limit` | 20 | Maksimal 100. | | `offset` | 0 | Untuk halaman berikutnya. | ```bash curl "https://temanqris.com/api/qris/orders?status=paid&limit=10" \ -H "X-API-Key: YOUR_API_KEY" ``` ```js const res = await fetch('https://temanqris.com/api/qris/orders?status=paid&limit=10&offset=0', { headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); const { orders, pagination } = await res.json(); ``` ```php 'paid', 'limit' => 10, 'offset' => 0])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python r = requests.get( "https://temanqris.com/api/qris/orders", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, params={"status": "paid", "limit": 10, "offset": 0}, timeout=15, ) orders = r.json()["orders"] ``` **Response `200 OK`** ```json { "success": true, "orders": [ { "order_id": "ORD-ABC123", "title": "Invoice #001", "amount": 50000, "total_amount": 51000, "status": "paid", "is_paid": true, "created_at": "2026-10-17T03:30:00.000Z", "paid_at": "2026-10-17T03:35:00.000Z" } ], "pagination": { "total": 25, "limit": 10, "offset": 0 } } ``` Order gateway di daftar ini juga membawa `provider_fee_amount` dan `net_amount`. --- ## Verifikasi Order URL: https://temanqris.com/docs#verify-order `POST /api/qris/orders/:orderId/verify` (memakai kuota) Tandai order manual sebagai `paid` lewat API, tanpa membuka dashboard. Panggil **hanya** setelah dana benar-benar terlihat di rekening atau e-wallet Anda. > **Kapan dipakai.** Setelah pelanggan menekan Sudah Bayar (status `awaiting_confirmation`), atau langsung dari status `pending` kalau Anda sudah melihat dana masuk (misalnya di kasir). > **Bukan untuk gateway.** Order gateway dikonfirmasi otomatis. Verifikasi manual ditolak dengan `409`. | Parameter | Tipe | Aturan | | --- | --- | --- | | `orderId` (URL) | string | Wajib. `order_id` Anda, misalnya `INV-2026-001`. | | `payer_name` (body) | string | Opsional. Nama pembayar, maksimal 100 karakter. | | `payer_note` (body) | string | Opsional. Catatan, maksimal 500 karakter. | ```bash curl -X POST https://temanqris.com/api/qris/orders/INV-2026-001/verify \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "payer_name": "John Doe", "payer_note": "Pembayaran via OVO" }' ``` ```js const res = await fetch(`https://temanqris.com/api/qris/orders/${encodeURIComponent('INV-2026-001')}/verify`, { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ payer_name: 'John Doe', payer_note: 'Pembayaran via OVO' }) }); const data = await res.json(); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['payer_name' => 'John Doe', 'payer_note' => 'Pembayaran via OVO']), ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python r = requests.post( "https://temanqris.com/api/qris/orders/INV-2026-001/verify", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, json={"payer_name": "John Doe", "payer_note": "Pembayaran via OVO"}, timeout=15, ) ``` **Response `200 OK`** ```json { "success": true, "message": "Payment verified successfully", "order": { "order_id": "INV-2026-001", "amount": 75000, "status": "paid", "paid_at": "2026-10-17T09:30:00.000Z", "confirmed_by": "api" } } ``` Setelah berhasil, webhook `payment.confirmed` dikirim kalau order punya `webhook_url` dan Anda sudah membuat webhook secret. **Error** | Status | Body | | --- | --- | | 404 | `{"success":false,"error":"Order not found","message":"No order found with order_id \"INV-999\" for your account."}` | | 400 | `{"success":false,"error":"Order already verified","message":"This order has already been marked as paid."}` | | 400 | `{"success":false,"error":"Order expired","message":"Cannot verify an expired order."}` | | 400 | `{"success":false,"error":"Order cancelled","message":"Cannot verify a cancelled order."}` | | 409 | `{"success":false,"error":"Gateway order requires automatic settlement reconciliation","message":"Manual verification is disabled for payment gateway orders."}` | **Alur integrasi manual** 1. Buat link via `POST /api/qris/payment-link` atau `POST /api/qris/generate`. 2. Bagikan link ke pelanggan. 3. Pelanggan scan QRIS dan membayar. 4. Pelanggan menekan Sudah Bayar, status menjadi `awaiting_confirmation`. 5. Anda menerima webhook `payment.awaiting_confirmation` dan email. 6. Cek status via `GET /api/qris/orders/:orderId`. 7. Cek dana masuk di e-wallet atau rekening. 8. Panggil `POST /api/qris/orders/:orderId/verify`. Status menjadi `paid`. > **Catatan production.** Panggil verify hanya dari backend internal setelah dana benar-benar masuk. Jangan buat tombol publik yang langsung memanggil endpoint ini. --- ## Batalkan Order URL: https://temanqris.com/docs#cancel-order `POST /api/qris/orders/:orderId/cancel` (memakai kuota) Batalkan order yang belum dibayar. Order `pending`, `awaiting_confirmation`, dan `expired` bisa dibatalkan. Link langsung tidak bisa dibayar lagi lewat halaman bayar. Untuk order gateway, pembatalan juga menghentikan pencocokan otomatis. Kalau pelanggan tetap membayar setelah dibatalkan, dana tidak dikreditkan otomatis dan masuk antrean tinjauan admin. ```bash curl -X POST https://temanqris.com/api/qris/orders/INV-2026-001/cancel \ -H "X-API-Key: YOUR_API_KEY" ``` ```js const res = await fetch(`https://temanqris.com/api/qris/orders/${encodeURIComponent('INV-2026-001')}/cancel`, { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python r = requests.post( "https://temanqris.com/api/qris/orders/INV-2026-001/cancel", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, timeout=15, ) ``` **Response `200 OK`** ```json { "success": true, "message": "Order cancelled successfully", "order": { "order_id": "INV-2026-001", "status": "cancelled" } } ``` **Error** | Status | Body | | --- | --- | | 400 | `{"success":false,"error":"Cannot cancel paid order","message":"This order has already been verified as paid and cannot be cancelled."}` | | 400 | `{"success":false,"error":"Order already cancelled","message":"This order has already been cancelled."}` | | 404 | `{"success":false,"error":"Order not found","message":"..."}` | | 409 | `{"success":false,"error":"Order state changed","message":"Order could not be cancelled because its status is now \"paid\"."}` | --- ## Arsipkan Payment Link URL: https://temanqris.com/docs#payment-link-delete `DELETE /api/qris/payment-link/:id` (memakai kuota) Arsipkan link yang tidak dipakai lagi. Link dinonaktifkan dan statusnya menjadi `cancelled` (atau tetap `expired`). Data tetap tersimpan untuk riwayat. `:id` adalah `payment_link.id` numerik dari response, bukan `link_code`. ```bash curl -X DELETE https://temanqris.com/api/qris/payment-link/456 \ -H "X-API-Key: YOUR_API_KEY" ``` ```js const res = await fetch('https://temanqris.com/api/qris/payment-link/456', { method: 'DELETE', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); ``` ```php 'DELETE', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python r = requests.delete( "https://temanqris.com/api/qris/payment-link/456", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, timeout=15, ) ``` **Response `200 OK`** ```json { "success": true, "message": "Payment link archived" } ``` **Error** | Status | Body | | --- | --- | | 404 | `{"error":"Payment link not found or unauthorized"}` | | 409 | `{"error":"Paid or confirmed payment links cannot be removed"}` | --- ## Pelanggan Sudah Bayar (publik) URL: https://temanqris.com/docs#payment-confirm `POST /api/pay/:link_code/confirm` (publik, tanpa API key, 30 request per menit per IP) Mencatat bahwa **pelanggan mengaku sudah membayar**. Status berubah menjadi `awaiting_confirmation` dan Anda perlu memverifikasi. > **Ini bukan konfirmasi pembayaran.** Endpoint ini hanya mengubah status ke `awaiting_confirmation`. Anda tetap harus mengecek bahwa dana sudah masuk di e-wallet atau rekening Anda. > **API publik.** Dipanggil dari halaman bayar saat pelanggan menekan tombol Sudah Bayar. Untuk link gateway endpoint ini tidak mengubah apa pun, karena pembayaran gateway diverifikasi otomatis. | Parameter | Tipe | Aturan | | --- | --- | --- | | `link_code` (URL) | string | Wajib. Kode payment link, misalnya `ABC12345XYZW`. | ```bash curl -X POST https://temanqris.com/api/pay/ABC12345XYZW/confirm \ -H "Content-Type: application/json" ``` ```js // Dari halaman bayar Anda sendiri (browser) const res = await fetch(`https://temanqris.com/api/pay/${linkCode}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await res.json(); if (data.success) showMessage('Konfirmasi terkirim. Merchant akan memeriksa pembayaran Anda.'); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python r = requests.post(f"https://temanqris.com/api/pay/{link_code}/confirm", timeout=15) ``` **Response `200 OK`** ```json { "success": true, "message": "Konfirmasi pembayaran berhasil dikirim", "order_id": "ORD-ABC12345XYZ", "status": "awaiting_confirmation" } ``` **Response untuk link gateway `202 Accepted`** ```json { "success": true, "payment_mode": "gateway", "message": "Pembayaran gateway diverifikasi otomatis. Tombol konfirmasi manual tidak diperlukan.", "status": "pending" } ``` **Error** | Status | Body | | --- | --- | | 404 | `{"error":"Link pembayaran tidak ditemukan"}` | | 400 | `{"error":"Pembayaran sudah dikonfirmasi, mohon tunggu verifikasi merchant."}` | | 400 | `{"error":"Link pembayaran sudah kedaluwarsa. Minta link baru ke merchant."}` | | 400 | `{"error":"Pesanan sudah cancelled. Tidak dapat dikonfirmasi."}` | **Yang terjadi setelah konfirmasi** 1. Pelanggan membuka halaman bayar (`/p/ABC12345XYZW`). 2. Pelanggan scan QRIS dan membayar di e-wallet. 3. Pelanggan menekan **Sudah Bayar**. 4. Halaman bayar memanggil `POST /api/pay/ABC12345XYZW/confirm`. 5. Status menjadi `awaiting_confirmation` (belum lunas). 6. Webhook `payment.awaiting_confirmation` dikirim ke `webhook_url` (kalau ada dan webhook secret sudah dibuat). 7. Anda menerima email notifikasi. 8. Pelanggan diarahkan ke `callback_url` (kalau ada). 9. Anda mengecek dana di dashboard bank atau e-wallet. 10. Setelah dana terlihat, verifikasi lewat [API](#verify-order) atau dashboard. Status menjadi `paid`. --- ## Status Link (publik) URL: https://temanqris.com/docs#pay-status `GET /api/pay/:link_code` (publik, tanpa API key, 120 request per menit per IP) Data yang dipakai halaman bayar. Berguna kalau Anda membuat halaman bayar sendiri dan ingin menampilkan QR serta status ke pelanggan. Response tidak pernah memuat biaya layanan gateway atau data internal. ```bash curl https://temanqris.com/api/pay/ABC12345XYZW ``` ```js const res = await fetch(`https://temanqris.com/api/pay/${linkCode}`); const link = await res.json(); if (link.is_paid) showPaid(); else if (link.is_expired) showExpired(); ``` ```php **Hanya di server.** Nominal, kode unik, idempotency, penguncian QR, dan konfirmasi pembayaran diproses dan diverifikasi di server TemanQRIS. Jangan mengambil keputusan pembayaran dari data callback browser. | Aturan | Kontrak v1.3.4 | | --- | --- | | Syarat akun | KYC harus disetujui untuk mode live. Tanpa KYC, request ditolak `403 KYC_REQUIRED`. Sandbox tidak butuh KYC. | | Nominal dasar maksimal | **Rp1.000.000** (`amount`). | | Total maksimal | **Rp1.000.999** per transaksi, termasuk fee dan kode unik. Admin bisa menurunkan batas ini. | | Kode unik | Bilangan bulat `0..999` yang dipilih server dari total yang masih bebas di seluruh platform, jadi dua invoice yang masih aktif tidak pernah punya total yang sama. Ikut masuk ke total QRIS, tidak bisa dikembalikan, dan menjadi pendapatan TemanQRIS. | | Biaya tambahan (opsional) | `fee_amount`: ditetapkan merchant sendiri, dibayar pelanggan, masuk penuh ke saldo Anda. Tidak boleh lebih besar dari `amount` (`fee_exceeds_base`). Pelanggan membayar `amount + fee_amount + unique_code`. | | Biaya layanan gateway | Dipotong dari dana yang Anda terima, bukan dari pelanggan. **0,8%** dari `amount` untuk semua nominal, dibulatkan ke atas ke rupiah penuh (Rp150.000 menjadi Rp1.200). Dihitung saat invoice dibuat dan disimpan di invoice itu, jadi perubahan tarif tidak mengubah invoice yang sudah terbit. Wallet bertambah `net_amount = amount + fee_amount - provider_fee_amount`. Invoice yang tidak menyisakan minimal Rp1 untuk Anda ditolak `amount_below_provider_fee`. | | Masa berlaku QR | 1 jam secara bawaan sejak diterbitkan (`expiresAt`). | | Masa tenggang bayar telat | Pembayaran yang dilakukan sampai 60 menit secara bawaan setelah QR kedaluwarsa (bisa diatur admin 0 sampai 300 menit) tetap dicocokkan dan dikreditkan. Halaman bayar sudah menampilkan kedaluwarsa, tapi pencocokan tetap berjalan. Batas untuk setiap invoice ada di `payable_until`. Kalau admin mengubah masa tenggang, `payable_until` invoice yang masih terbuka ikut berubah, tapi tidak pernah melewati batas yang dikunci saat QR terbit. Jadi baca ulang lewat `GET /api/qris/orders/:orderId` sebelum menutup order, jangan hanya menyimpan nilai dari response create. | | Saldo tertahan (H+1) | Dana gateway masuk ke wallet sebagai saldo tertahan dan bisa ditarik mulai **00.00 WIB hari berikutnya**. Lihat [Biaya, Saldo, Refund](#gateway-money). | | Order ID | Satu `order_id` hanya punya satu pembayaran gateway, apa pun hasilnya. Untuk mengulang pembayaran yang gagal atau kedaluwarsa, kirim `order_id` baru. | | Waktu jawab | TemanQRIS selalu menjawab dalam **20 detik**, termasuk saat pindah ke penyedia cadangan. Kalau penyedia terlalu lambat, jawabannya `503 provider_timeout`: tidak ada QR, order ditandai `cancelled`, dan nominalnya dilepas. Set timeout klien minimal **25 detik**, atau kirim header opsional `X-Request-Timeout-Ms` (milidetik) berisi batas tunggu Anda. TemanQRIS lalu berhenti 1 detik sebelum batas itu (paling cepat 1 detik, paling lama tetap 20 detik). | | Idempotency | Header `Idempotency-Key` wajib. 1 sampai 256 karakter ASCII yang bisa dicetak. Dihitung bersama akun dan `order_id`, disimpan sebagai hash SHA-256, dan mengingat isi request pertama. | | Invoice terbuka | Maksimal 50 invoice yang masih terbuka per merchant (bawaan). | | Batas pembuatan | Key live tidak memakai kuota harian tier. Batasnya 60 invoice baru per menit per merchant (bawaan, `429 gateway_rate_limited`). Akun yang baru lolos KYC bisa diberi batas total nominal harian (`403 new_account_daily_amount_cap`). Lihat [Batas Payment Gateway live](#limits-batas-payment-gateway-live). | | Status pembayaran | Webhook `payment.confirmed`, atau baca `GET /api/qris/orders/:orderId`. | | Batal | `POST /api/qris/orders/:orderId/cancel` atau arsip link menghentikan pencocokan. Pembayaran yang masuk setelahnya ditinjau admin. | | Routing | Routing, penguncian, dan rekonsiliasi dikelola server. Anda cukup memakai payment link. | | Checkout publik | Pakai `payment_link.url` dan `payment_link.qr_image` dari response. | --- ## Buat Pembayaran Gateway URL: https://temanqris.com/docs#gateway-create `POST /api/payment-gateway/payments` (tanpa kuota harian untuk key live, 60 invoice per menit (bawaan), kuota sandbox untuk key `tq_test_`) Buat satu invoice gateway berisi QRIS dinamis, kode unik, dan link pembayaran. **Header** | Header | Wajib | Keterangan | | --- | --- | --- | | `X-API-Key` | Ya, untuk integrasi | Key live dari Dashboard Settings, atau key `tq_test_` untuk Sandbox. Key menentukan mode. | | `Authorization: Bearer ...` | Alternatif | Sesi dashboard. Untuk sandbox kirim `"mode": "sandbox"` di body. | | `Idempotency-Key` | **Ya** | Key unik per percobaan create. Pakai key yang sama saat retry timeout. Body `idempotency_key` diterima sebagai cadangan kalau header tidak bisa dikirim. | | `Content-Type: application/json` | Ya | Body JSON, maksimal 10 KB. | **Request body** | Field | Tipe | Aturan | | --- | --- | --- | | `order_id` | string, wajib | Maksimal 30 karakter. Identitas order Anda. Satu `order_id` hanya punya satu pembayaran gateway. | | `amount` | integer, wajib | Nominal dasar positif, maksimal 1.000.000. Angka bulat seperti `150000` atau `"150000"`. Desimal, titik ribuan, atau notasi seperti `"1e5"` ditolak. | | `fee_amount` | integer, opsional | Biaya tambahan (opsional): ditetapkan merchant sendiri, dibayar pelanggan, masuk penuh ke saldo Anda. Rupiah bulat 0 atau lebih, maksimal sama dengan `amount`. Bawaan `0`. | | `title` | string, opsional | Nama invoice, dipotong maksimal 100 karakter. | | `description` | string, opsional | Deskripsi, dipotong maksimal 255 karakter. | | `webhook_url` | URL, opsional | HTTP(S), wajib HTTPS di production. Host harus mengarah ke alamat publik. Alamat private, loopback, link-local, dan range khusus lain ditolak. | | `callback_url` | URL, opsional | Redirect http(s) untuk pelanggan. Bukan bukti pembayaran. | **Request** ```bash curl -X POST https://temanqris.com/api/payment-gateway/payments \ -H "X-API-Key: YOUR_API_KEY" \ -H "Idempotency-Key: INV-2026-0001-attempt-01" \ -H "Content-Type: application/json" \ -d '{ "order_id": "INV-2026-0001", "amount": 150000, "fee_amount": 0, "title": "Pembayaran Paket Premium", "description": "Pesanan pelanggan INV-2026-0001", "webhook_url": "https://merchant.example/webhooks/temanqris", "callback_url": "https://merchant.example/orders/INV-2026-0001/success" }' ``` ```js const res = await fetch('https://temanqris.com/api/payment-gateway/payments', { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY, 'Idempotency-Key': 'INV-2026-0001-attempt-01', 'Content-Type': 'application/json' }, body: JSON.stringify({ order_id: 'INV-2026-0001', amount: 150000, fee_amount: 0, title: 'Pembayaran Paket Premium', description: 'Pesanan pelanggan INV-2026-0001', webhook_url: 'https://merchant.example/webhooks/temanqris', callback_url: 'https://merchant.example/orders/INV-2026-0001/success' }) }); const data = await res.json(); if (!res.ok) throw new Error(`${data.code || res.status}: ${data.error}`); const payUrl = 'https://temanqris.com' + data.payment_link.url; ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'X-API-Key: ' . getenv('TEMANQRIS_API_KEY'), 'Idempotency-Key: INV-2026-0001-attempt-01', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'order_id' => 'INV-2026-0001', 'amount' => 150000, 'fee_amount' => 0, 'title' => 'Pembayaran Paket Premium', 'description' => 'Pesanan pelanggan INV-2026-0001', 'webhook_url' => 'https://merchant.example/webhooks/temanqris', 'callback_url' => 'https://merchant.example/orders/INV-2026-0001/success', ]), ]); $data = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200 && $status !== 201) { throw new RuntimeException(($data['code'] ?? $status) . ': ' . ($data['error'] ?? '')); } ``` ```python r = requests.post( "https://temanqris.com/api/payment-gateway/payments", headers={ "X-API-Key": os.environ["TEMANQRIS_API_KEY"], "Idempotency-Key": "INV-2026-0001-attempt-01", }, json={ "order_id": "INV-2026-0001", "amount": 150000, "fee_amount": 0, "title": "Pembayaran Paket Premium", "description": "Pesanan pelanggan INV-2026-0001", "webhook_url": "https://merchant.example/webhooks/temanqris", "callback_url": "https://merchant.example/orders/INV-2026-0001/success", }, timeout=20, ) data = r.json() if r.status_code not in (200, 201): raise RuntimeError(f"{data.get('code', r.status_code)}: {data.get('error')}") ``` **Response `201 Created`** ```json { "success": true, "existing": false, "payment_mode": "gateway", "payment_attempt_id": "2de4a4d5-3b0e-4b7a-9a3e-4c9f7a2b1e55", "payment_link": { "id": 456, "linkCode": "aZ3kQ9mP2xLw", "orderId": "INV-2026-0001", "amount": 150000, "feeAmount": 0, "provider_fee_amount": 1200, "net_amount": 148800, "uniqueCode": 321, "totalAmount": 150321, "description": "Pesanan pelanggan INV-2026-0001", "expiresAt": "2026-10-17T03:15:00.000Z", "payable_until": "2026-10-17T04:15:00.000Z", "url": "/p/aZ3kQ9mP2xLw", "qr_image": "data:image/png;base64,iVBOR..." }, "mode": "live" } ``` | Field | Arti | | --- | --- | | `payment_attempt_id` | ID percobaan pembayaran. Simpan untuk tracing dan saat menghubungi support. | | `payment_link.totalAmount` | Yang harus dibayar pelanggan: `amount + feeAmount + uniqueCode`. | | `payment_link.provider_fee_amount` | Biaya layanan gateway untuk invoice ini. | | `payment_link.net_amount` | Yang masuk ke wallet Anda saat lunas. | | `payment_link.expiresAt` | QR kedaluwarsa (1 jam secara bawaan). | | `payment_link.payable_until` | Batas terakhir pembayaran yang masih dicocokkan dan dikreditkan otomatis: `expiresAt` ditambah masa tenggang (60 menit secara bawaan). Pakai ini, bukan angka tetap, untuk menentukan kapan order Anda boleh dianggap gagal. `null` kalau invoice sudah lunas atau dibatalkan. | | `payment_link.url`, `payment_link.qr_image` | Untuk checkout. Field stabil yang sebaiknya Anda pakai. | | `mode` | `live` atau `sandbox`. | > **Catatan.** Beberapa field di `payment_link` memakai camelCase (`linkCode`, `orderId`, `feeAmount`, `uniqueCode`, `totalAmount`, `expiresAt`). Nama ini tetap, jadi aman dipakai. **Request ulang (idempotent replay)** Kalau request diulang dengan akun, `order_id`, `Idempotency-Key`, **dan isi yang sama**, server mengembalikan `200 OK` dengan `existing: true` dan invoice yang sama dalam bentuk lengkap. Jangan membuat invoice kedua dan jangan mengganti key saat retry timeout. Server mengingat isi request pertama (`amount`, `fee_amount`, `order_id`, `title`, `description`, `webhook_url`, `callback_url`). Key yang sama dengan isi berbeda ditolak `422 idempotency_key_reused`. **Response replay `200 OK`** ```json { "success": true, "existing": true, "payment_mode": "gateway", "payment_attempt_id": "2de4a4d5-3b0e-4b7a-9a3e-4c9f7a2b1e55", "payment_link": { "id": 456, "linkCode": "aZ3kQ9mP2xLw", "orderId": "INV-2026-0001", "amount": 150000, "feeAmount": 0, "provider_fee_amount": 1200, "net_amount": 148800, "uniqueCode": 321, "totalAmount": 150321, "description": "Pesanan pelanggan INV-2026-0001", "expiresAt": "2026-10-17T03:15:00.000Z", "payable_until": "2026-10-17T04:15:00.000Z", "url": "/p/aZ3kQ9mP2xLw", "qr_image": "data:image/png;base64,iVBOR..." }, "mode": "live" } ``` **Error `409`** ```json { "error": "This order_id already has a gateway payment. Use a new order_id to try again.", "code": "gateway_order_id_used" } ``` **Error `422`** ```json { "error": "This Idempotency-Key was already used for a different request. Send the original request again to get its payment, or use a new Idempotency-Key.", "code": "idempotency_key_reused" } ``` **Mengulang setelah gagal atau kedaluwarsa** Satu `order_id` hanya boleh dipakai untuk satu pembayaran gateway, apa pun statusnya. Mengganti `Idempotency-Key` saja tidak membuka order yang sama lagi, dan mengulang key lama untuk pembayaran yang sudah gagal atau kedaluwarsa menghasilkan `409 gateway_order_id_used`. Kirim `order_id` baru, misalnya `INV-2026-0001-R2`. Key dihitung bersama `order_id`, jadi key yang sama dengan `order_id` berbeda membuat pembayaran terpisah. **Error** | Status | Arti | Contoh body | | --- | --- | --- | | 400 | Input atau idempotency tidak valid. Selalu ada `code`. | `invalid_order_id`, `missing_idempotency_key`, `invalid_idempotency_key`, `invalid_amount`, `amount_too_large`, `invalid_fee_amount`, `fee_exceeds_base`, `amount_below_provider_fee`, `total_amount_too_large`, `gateway_total_exceeds_cap`, `invalid_webhook_url`, `invalid_callback_url` | | 400 | Body JSON rusak. | `{"error":"...","code":400}` | | 401 | Credential tidak ada atau salah. | `API key required (send it in the x-api-key header)` atau `Invalid API key` | | 403 | KYC belum disetujui. | `{"error":"KYC verification is required before using automatic payment gateway","code":"KYC_REQUIRED","kyc_status":"pending"}` | | 403 | Akun nonaktif atau JWT tidak valid. | `Account is deactivated`, `{"error":"merchant account is inactive","code":"account_inactive"}`, `Invalid or expired token` | | 403 | Total nominal hari ini sudah mencapai batas akun yang baru lolos KYC. Kode `new_account_daily_amount_cap`. Hanya kalau diaktifkan admin. | `Daily payment gateway amount limit reached for a newly verified account` | | 409 | `order_id` sudah punya pembayaran gateway (berjalan, gagal, atau kedaluwarsa). Kode `gateway_order_id_used`. | `This order_id already has a gateway payment. Use a new order_id to try again.` | | 409 | `order_id` dipakai pembayaran lain milik Anda, misalnya QRIS manual. Kode `gateway_order_id_used`. | `This order_id is already used by another payment. Use a new order_id to try again.` | | 409 | Key sedang dipakai request lain untuk order yang sama. Kode `idempotency_key_in_use`. | `This Idempotency-Key is already in use for this order. Retry with the same request to get the existing payment.` | | 409 | Request pertama untuk key ini masih diproses. Kode `gateway_creation_in_progress`. | Kirim ulang request yang sama beberapa detik lagi. | | 422 | Key sudah dipakai untuk isi berbeda. Kode `idempotency_key_reused`. | `This Idempotency-Key was already used for a different request. Send the original request again to get its payment, or use a new Idempotency-Key.` | | 429 | Terlalu banyak invoice terbuka. Kode `gateway_open_limit`. | `Too many open gateway payments. Wait for some to be paid or expire.` | | 429 | Terlalu banyak invoice dibuat dalam satu menit (key live). Kode `gateway_rate_limited`, dengan header `Retry-After`. | `Too many gateway payments created in a short time. Try again shortly.` | | 429 | Kuota harian Sandbox habis (key `tq_test_`, tanpa `code`, `"mode": "sandbox"`). | `Rate limit exceeded` | | 503 | Semua kode unik untuk nominal ini dipakai. Kode `gateway_amount_busy`. | `All unique codes for this amount are in use. Try again in a few minutes or use a different amount.` | | 503 | Reservasi nominal lepas saat menyiapkan. Kode `gateway_amount_reservation_lost`. | Buat pembayaran baru dengan `order_id` baru. | | 503 | DNS sedang bermasalah saat memeriksa `webhook_url`. Kode `webhook_dns_unavailable`. | Kirim ulang request yang sama. Replay pembayaran yang sudah ada tidak butuh pemeriksaan ini. | | 503 | Gateway dimatikan admin atau belum dikonfigurasi (tanpa `code`). | `Payment gateway mode is disabled by admin` | | 500 | Kesalahan internal saat menyiapkan pembayaran. | `Failed to create gateway payment`. Ulangi dengan key yang sama, lalu cek status. | **Error `400` (input tidak valid)** ```json { "error": "fee amount must not exceed base amount", "code": "fee_exceeds_base" } ``` **Error `403` (batas nominal akun baru)** ```json { "error": "Daily payment gateway amount limit reached for a newly verified account", "code": "new_account_daily_amount_cap", "limit": 5000000, "used": 4900000, "resetTime": "Midnight (UTC)" } ``` **Error `429` (batas per menit)** ```json { "error": "Too many gateway payments created in a short time. Try again shortly.", "code": "gateway_rate_limited", "limit": 60, "window_seconds": 60, "retry_after_seconds": 18 } ``` Response `429` membawa header `Retry-After` dalam detik. Response yang lolos membawa `X-RateLimit-Limit`, `X-RateLimit-Remaining`, dan `X-RateLimit-Reset` untuk jendela per menit (key live) atau kuota harian Sandbox (key `tq_test_`). --- ## Status Gateway URL: https://temanqris.com/docs#gateway-status `GET /api/payment-gateway/status` (tidak memakai kuota) Ambil status gateway, akses KYC, tarif biaya layanan, dan jumlah invoice yang masih terbuka. Tetap bisa dipanggil saat gateway dimatikan (`enabled: false`). Dengan key `tq_test_`, response menggambarkan Sandbox. ```bash curl https://temanqris.com/api/payment-gateway/status \ -H "X-API-Key: YOUR_API_KEY" ``` ```js const res = await fetch('https://temanqris.com/api/payment-gateway/status', { headers: { 'X-API-Key': process.env.TEMANQRIS_API_KEY } }); const status = await res.json(); const canUseGateway = status.enabled && status.gateway_access === 'available'; ``` ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $status = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python status = requests.get( "https://temanqris.com/api/payment-gateway/status", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, timeout=15, ).json() ``` **Response `200 OK`** ```json { "enabled": true, "payment_mode": "gateway", "mode": "live", "max_base_amount": 1000000, "max_total_amount": 1000999, "open_attempt_count": 2, "provider_fee": { "percent_bps": 80, "flat_amount": 0, "threshold_amount": 1, "below_threshold_percent_bps": 80 }, "kyc_status": "approved", "gateway_access": "available" } ``` | Field | Arti | | --- | --- | | `enabled` | Gateway live sedang aktif. Selalu `true` di Sandbox. | | `open_attempt_count` | Invoice yang masih terbuka (dibuat atau QR aktif). | | `provider_fee` | Tarif biaya layanan untuk invoice **baru**. `percent_bps` dalam basis poin (80 = 0,8%) berlaku untuk nominal dasar mulai `threshold_amount`. Di bawahnya biayanya `flat_amount` ditambah `below_threshold_percent_bps` dari nominal dasar, dibulatkan ke atas. Saat ini `percent_bps` dan `below_threshold_percent_bps` sama-sama `80` dengan `threshold_amount` `1` dan `flat_amount` `0`, jadi semua nominal dikenai 0,8%. | | `kyc_status` | `not_started`, `pending`, `needs_action`, `approved`, `rejected`, atau `expired`. Sandbox: `not_required`. | | `gateway_access` | `available` kalau KYC disetujui, `kyc_required` kalau belum. | > Response hanya menampilkan status yang diperlukan untuk integrasi. Detail routing dan rekonsiliasi tetap di server. --- ## Mode Sandbox URL: https://temanqris.com/docs#gateway-sandbox Uji seluruh alur gateway tanpa uang sungguhan: buat invoice, buka halaman bayar, terima webhook bertanda tangan, lalu cek hasilnya. Endpoint, body request, dan cara verifikasi webhook sama dengan mode live. Yang menentukan mode hanya API key yang Anda kirim. > **Tanpa KYC, tanpa uang sungguhan.** Sandbox terbuka untuk semua merchant dan tetap jalan walau gateway live sedang dimatikan. Sandbox tidak menyentuh saldo, wallet, atau pendapatan. **Live dan Sandbox** | Aspek | Live | Sandbox | | --- | --- | --- | | API key | 64 karakter heksadesimal dari Dashboard Settings | `tq_test_` + 48 karakter heksadesimal dari console Sandbox | | Webhook secret | 64 karakter heksadesimal | `whsec_test_` + 48 karakter heksadesimal | | `X-TemanQRIS-Mode` dan `data.mode` | `live` | `sandbox` | | KYC | Wajib disetujui | Tidak perlu | | Uang dan saldo | Uang sungguhan, masuk wallet sebagai saldo tertahan (H+1) | Tidak ada uang, saldo tidak berubah | | QR | Bisa dibayar, berlaku 1 jam secara bawaan | Sengaja tidak bisa dibayar, hasil disimulasikan | | Kode unik | Unik di seluruh platform | Diacak tanpa reservasi | | Kuota | Kuota harian tier | 500 panggilan per hari, terpisah | | Halaman bayar | `/p/{linkCode}` | `/p/sb_...` berlabel SANDBOX | | Data | Disimpan | Dihapus otomatis setelah 30 hari | **Mendapatkan sandbox key** 1. **Buka dashboard.** Masuk ke [Payment Gateway](/dashboard/payment-gateway), lalu geser saklar ke **Sandbox**. Key dibuat otomatis saat pertama kali dibuka. 2. **Tampilkan key.** Key lengkap hanya muncul setelah Anda memasukkan password. Bentuknya `tq_test_` diikuti 48 karakter heksadesimal. 3. **Ganti kalau perlu.** Tombol rotate juga meminta password. Key lama langsung berhenti bekerja. **Pakai di endpoint yang sama** Kirim sandbox key di header `X-API-Key` ke `POST /api/payment-gateway/payments` dan `GET /api/payment-gateway/status`. Setiap response berisi `"mode": "sandbox"` (response live berisi `"mode": "live"`). Sandbox key hanya diterima di dua endpoint itu dan di dua endpoint sandbox di bawah (cek invoice dan simulasi). Di endpoint API lain key itu ditolak dengan `401`. ```bash curl -X POST https://temanqris.com/api/payment-gateway/payments \ -H "X-API-Key: tq_test_KEY_SANDBOX_ANDA" \ -H "Idempotency-Key: INV-2026-0001-attempt-01" \ -H "Content-Type: application/json" \ -d '{"order_id":"INV-2026-0001","amount":150000,"webhook_url":"https://merchant.example/webhooks/temanqris"}' ``` Aturan nominal, fee, biaya layanan, `order_id`, `Idempotency-Key`, dan validasi `webhook_url` sama dengan live, jadi error yang Anda temui di sandbox adalah error yang sama di live. Response create sama dengan live ditambah `"paid_late": false`. `payment_link.payable_until` di sandbox adalah `expiresAt` ditambah masa tenggang live, dan `null` setelah invoice lunas. Bedanya: - Kode unik diacak 0 sampai 999 tanpa reservasi, sehingga dua invoice sandbox bisa punya total yang sama. - Invoice sandbox berlaku 1 jam secara bawaan, sama seperti live. Invoice yang lewat waktu tampil `expired`, dan replay untuk invoice itu ditolak `409 gateway_order_id_used` seperti live. - `order_id` sandbox terpisah dari live. Anda boleh memakai `order_id` yang sama di kedua mode. - Link halaman bayar berbentuk `/p/sb_...`. **Kuota sandbox** | Panggilan | Batas | | --- | --- | | Buat invoice, simulasi, dan tombol console yang menulis (buat, simulasi, kirim ulang) | Kuota sendiri `sandbox_daily_limit`, bawaan **500 per hari**, terpisah dari kuota live. Lewat batas: `429` dengan `"mode": "sandbox"`. Reset 00.00 UTC. | | Cek invoice (`GET .../sandbox/payments/:order_id`) | Tidak memakai kuota harian. Maksimal 120 per menit per merchant (`429`, `code: "rate_limited"`). | | Status gateway dengan sandbox key | Tidak dibatasi kuota. | Daftar invoice sandbox, kirim ulang webhook, dan hapus data sandbox ada di console dashboard (butuh login), bukan lewat API key. **QR sandbox tidak bisa dibayar** QR di invoice sandbox sengaja tidak valid, jadi tidak ada e-wallet yang bisa membayarnya. Halaman bayar `/p/sb_...` diberi label **SANDBOX, jangan dibayar**. Hasil pembayaran Anda simulasikan lewat salah satu cara ini: - Tombol di halaman bayar sandbox: **Simulasikan bayar**, **Simulasikan kedaluwarsa**, dan **Simulasikan bayar telat**. Tombol yang sama ada di console dashboard. - API simulasi, hanya dengan sandbox key. #### Cek invoice sandbox `GET /api/payment-gateway/sandbox/payments/:order_id` (sandbox key saja) ```bash curl https://temanqris.com/api/payment-gateway/sandbox/payments/INV-2026-0001 \ -H "X-API-Key: tq_test_KEY_SANDBOX_ANDA" ``` ```js const res = await fetch('https://temanqris.com/api/payment-gateway/sandbox/payments/INV-2026-0001', { headers: { 'X-API-Key': process.env.TEMANQRIS_SANDBOX_KEY } }); const invoice = await res.json(); // invoice.status: qr_issued, paid, expired ``` ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_SANDBOX_KEY')], ]); $invoice = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python invoice = requests.get( "https://temanqris.com/api/payment-gateway/sandbox/payments/INV-2026-0001", headers={"X-API-Key": os.environ["TEMANQRIS_SANDBOX_KEY"]}, timeout=15, ).json() ``` Response sama dengan saat membuat invoice (dengan `existing: true`), ditambah `status` (`qr_issued`, `paid`, `expired`), `paid_at`, dan `paid_late`. Invoice terbuka yang melewati waktu kedaluwarsa tampil `expired`. Tidak ditemukan: `404 sandbox_payment_not_found`. **Response `200 OK`** ```json { "success": true, "existing": true, "payment_mode": "gateway", "payment_attempt_id": "7b0f3c2e-9a41-4d5e-8c3b-1f2e3d4c5b6a", "payment_link": { "id": 31, "linkCode": "sb_Xk2mQ9aZ3pLw7RtY", "orderId": "INV-2026-0001", "amount": 150000, "feeAmount": 0, "provider_fee_amount": 1200, "net_amount": 148800, "uniqueCode": 87, "totalAmount": 150087, "description": null, "expiresAt": "2026-10-17T03:15:00.000Z", "payable_until": "2026-10-17T04:15:00.000Z", "url": "/p/sb_Xk2mQ9aZ3pLw7RtY", "qr_image": null }, "mode": "sandbox", "paid_late": false, "status": "qr_issued", "paid_at": null } ``` #### Simulasikan hasil pembayaran `POST /api/payment-gateway/sandbox/payments/:order_id/simulate` (sandbox key saja, memakai kuota sandbox) ```bash curl -X POST https://temanqris.com/api/payment-gateway/sandbox/payments/INV-2026-0001/simulate \ -H "X-API-Key: tq_test_KEY_SANDBOX_ANDA" \ -H "Content-Type: application/json" \ -d '{"result":"paid"}' ``` ```js const res = await fetch('https://temanqris.com/api/payment-gateway/sandbox/payments/INV-2026-0001/simulate', { method: 'POST', headers: { 'X-API-Key': process.env.TEMANQRIS_SANDBOX_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ result: 'paid' }) }); const out = await res.json(); // out.webhook_queued, out.webhook_reason ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_SANDBOX_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['result' => 'paid']), ]); $out = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python out = requests.post( "https://temanqris.com/api/payment-gateway/sandbox/payments/INV-2026-0001/simulate", headers={"X-API-Key": os.environ["TEMANQRIS_SANDBOX_KEY"]}, json={"result": "paid"}, timeout=15, ).json() ``` | `result` | Efek | | --- | --- | | `paid` | Invoice yang masih terbuka menjadi `paid` dan webhook `payment.confirmed` masuk antrean. | | `expired` | Invoice yang masih terbuka menjadi `expired`. | | `late_paid` | Invoice terbuka atau yang sudah kedaluwarsa menjadi `paid` dengan `paid_late: true`, seperti pembayaran telat di masa tenggang live. | Response: bentuk yang sama dengan cek invoice, ditambah `changed` (`true`), `webhook_queued` (`true` atau `false`), dan `webhook_reason` (`null` kalau webhook masuk antrean; `not_paid` untuk `expired`; `no_webhook_url`; atau `no_webhook_secret`). **Response `200 OK`** ```json { "success": true, "existing": true, "payment_mode": "gateway", "payment_attempt_id": "7b0f3c2e-9a41-4d5e-8c3b-1f2e3d4c5b6a", "payment_link": { "linkCode": "sb_Xk2mQ9aZ3pLw7RtY", "orderId": "INV-2026-0001", "totalAmount": 150087, "...": "..." }, "mode": "sandbox", "paid_late": false, "changed": true, "webhook_queued": true, "webhook_reason": null, "status": "paid", "paid_at": "2026-10-17T03:05:41.000Z" } ``` **Error `400`** ```json { "error": "result must be paid, expired, or late_paid", "code": "sandbox_invalid_result" } ``` **Kode error sandbox** | Status | Kode | Arti | | --- | --- | --- | | 400 | `sandbox_invalid_result` | `result` bukan `paid`, `expired`, atau `late_paid`. | | 403 | `wrong_gateway_mode` | Endpoint ini butuh sandbox key, tetapi Anda mengirim key live. | | 404 | `sandbox_payment_not_found` | Tidak ada invoice sandbox dengan `order_id` itu di akun Anda. | | 409 | `sandbox_invalid_transition` | Invoice sudah dibayar atau sudah kedaluwarsa, jadi hasil itu tidak berlaku. Hanya `late_paid` yang boleh dipakai pada invoice kedaluwarsa. | | 409 | `sandbox_webhook_pending` | Kirim ulang webhook ditolak: webhook sebelumnya masih dalam antrean. | | 409 | `sandbox_no_webhook_event` | Kirim ulang webhook ditolak: belum ada pembayaran yang disimulasikan. | | 409 | `sandbox_no_webhook_url` | Kirim ulang webhook ditolak: invoice tidak punya `webhook_url`. | | 409 | `sandbox_no_webhook_secret` | Kirim ulang webhook ditolak: webhook secret sandbox belum bisa disiapkan. Coba lagi nanti. | | 429 | `rate_limited` atau tanpa `code` | Batas per menit (cek invoice) atau kuota harian sandbox habis. Body berisi `"mode": "sandbox"`. | **Webhook sandbox** - Ditandatangani dengan **webhook secret sandbox** (`whsec_test_` diikuti 48 karakter hex), bukan webhook secret live. Lihat, salin, atau buat ulang secret ini di console sandbox (butuh password). Server uji Anda harus memverifikasi dengan secret ini. - Header sama dengan live (`X-TemanQRIS-Signature`, `X-TemanQRIS-Event`, `X-TemanQRIS-Idempotency-Key`, `X-TemanQRIS-Retry`) ditambah `X-TemanQRIS-Mode: sandbox`. Handler production Anda harus menolak setiap webhook dengan `X-TemanQRIS-Mode: sandbox`. Webhook live selalu membawa `X-TemanQRIS-Mode: live` dan `data.mode` bernilai `live`. - Isi `data` sama dengan live, dengan `"mode": "sandbox"` dan tambahan `"paid_late"` (`true` untuk `late_paid`). - Webhook hanya dikirim kalau invoice punya `webhook_url`. Secret sandbox dibuat otomatis saat webhook pertama atau saat Anda pertama membuka console. Response simulasi lewat API dan console memuat `webhook_queued` dan `webhook_reason`. Halaman bayar sandbox hanya menerima `webhook_queued`, tanpa alasannya. - Dikirim oleh worker, biasanya dalam sekitar satu menit setelah simulasi, dengan jadwal retry yang sama dengan live: 30 detik, 2 menit, 10 menit, maksimal 4 percobaan. Webhook sandbox yang gagal tidak memicu peringatan ke admin. - Tombol **Kirim ulang webhook** di console mengirim event terakhir lagi dengan **idempotency key yang sama**, jadi Anda bisa menguji penanganan duplikat. - Urutan key JSON bisa berbeda dari live. Selalu verifikasi signature terhadap **raw body**, jangan terhadap JSON yang sudah diserialisasi ulang. ```json { "event": "payment.confirmed", "idempotency_key": "3b1c9d2e-4f5a-4b6c-8d7e-9f0a1b2c3d4e", "timestamp": "2026-10-17T03:06:12.000Z", "data": { "order_id": "INV-2026-0001", "base_amount": 150000, "total_amount": 150087, "status": "paid", "mode": "sandbox", "paid_late": false } } ``` (Ringkas. Payload lengkap sama dengan [webhook gateway](#gateway-webhooks).) **Pindah ke live** Pastikan KYC sudah disetujui, lalu ganti sandbox key dengan key live dan webhook secret sandbox dengan webhook secret live di server Anda. Endpoint, body request, dan cara verifikasi webhook tetap sama. Pastikan handler production hanya menerima `X-TemanQRIS-Mode: live`. Checklist lengkap ada di [Sandbox ke Live](#use-case-sandbox). **Data sandbox** Invoice dan log webhook sandbox disimpan **30 hari**, lalu dihapus otomatis setiap jam. Anda bisa menghapusnya lebih awal lewat tombol **Hapus data sandbox** di console (butuh password). Admin hanya melihat jumlah pemakaian sandbox, bukan isi data Anda. --- ## Biaya, Saldo Tertahan, dan Refund URL: https://temanqris.com/docs#gateway-money **Rincian uang dalam satu pembayaran** | Komponen | Dibayar pelanggan | Masuk ke wallet Anda | Ke TemanQRIS | | --- | --- | --- | --- | | `amount` (nominal dasar) | Ya | Ya | | | `fee_amount` (biaya tambahan, opsional) | Ya | Ya | | | `unique_code` (0 sampai 999) | Ya | | Ya | | `provider_fee_amount` (biaya layanan) | | Dipotong | Ya | - `total_amount = amount + fee_amount + unique_code` - `net_amount = amount + fee_amount - provider_fee_amount` **Contoh perhitungan biaya layanan (0,8%)** | `amount` | `fee_amount` | Biaya layanan | `net_amount` | | --- | --- | --- | --- | | 10.000 | 0 | 80 | 9.920 | | 50.000 | 0 | 400 | 49.600 | | 50.000 | 400 | 400 | 50.000 | | 99.999 | 0 | 800 (799,992 dibulatkan ke atas) | 99.199 | | 100.000 | 0 | 800 | 99.200 | | 150.000 | 0 | 1.200 | 148.800 | | 1.000.000 | 0 | 8.000 | 992.000 | | 1 | 0 | 1 | 0, ditolak `amount_below_provider_fee` | > **Mau pelanggan yang menanggung biaya layanan?** Isi `fee_amount` sebesar `ceil(amount x 0,008)`. Dengan begitu `net_amount` sama dengan `amount`. Selalu ambil tarif terbaru dari `GET /api/payment-gateway/status`. **Saldo tertahan (H+1)** - Dana gateway masuk ke wallet sebagai **saldo tertahan** dan bisa ditarik mulai **00.00 WIB hari berikutnya** setelah waktu pembayaran. - Pembayaran pukul 23.50 WIB tersedia 10 menit kemudian. Pembayaran pukul 00.10 WIB tersedia hampir 24 jam kemudian. - Saldo tertahan tetap terlihat di Wallet dan Saldo, tapi belum bisa ditarik. - Wallet yang sedang dikunci tetap menerima dana. Yang diblokir hanya penarikan. - Webhook `payment.confirmed` dikirim saat pembayaran terkonfirmasi, bukan saat dana bisa ditarik. - Penarikan saldo dikenai biaya 1% dari nominal penarikan (dibulatkan ke atas), minimal Rp2.500 dan maksimal Rp4.500. **Refund dan penarikan kembali dana** - Refund tidak tersedia lewat API. Kalau pelanggan meminta refund, hubungi support TemanQRIS. - Kalau refund terjadi di sisi penyedia pembayaran untuk transaksi yang sudah dikreditkan ke Anda, sistem mencatatnya dan admin meninjaunya. **Tidak ada dana yang ditarik otomatis.** - Kalau admin menyetujui penarikan kembali (reversal), dana diambil dari saldo tertahan lebih dulu, lalu dari saldo tersedia. Jumlahnya tidak pernah melebihi dana yang dikreditkan untuk pembayaran itu. - Kalau saldo tidak cukup, kekurangannya dicatat dan wallet dikunci untuk penarikan sampai diselesaikan bersama admin. - Tidak ada webhook untuk refund atau reversal. Perubahan terlihat di riwayat wallet. **Pembayaran yang tidak cocok** Pembayaran dengan nominal yang tidak sama dengan total invoice mana pun, pembayaran setelah `payable_until` (masa tenggang habis), atau pembayaran ke order yang sudah dibatalkan tidak dikreditkan otomatis. Pembayaran itu masuk antrean tinjauan admin. Minta pelanggan menghubungi support dengan bukti transfer. --- ## Webhook URL: https://temanqris.com/docs#webhooks Terima notifikasi real-time saat status pembayaran berubah. Webhook dikirim ke `webhook_url` yang Anda isi saat membuat pembayaran: lewat `POST /api/qris/generate`, `POST /api/qris/payment-link`, atribut widget `data-webhook`, atau `POST /api/payment-gateway/payments`. > **Wajib: buat webhook secret dulu.** Buka Dashboard Settings dan buat webhook secret (64 karakter heksadesimal, butuh password untuk melihat atau membuat ulang). Tanpa secret, TemanQRIS **tidak mengirim webhook apa pun**, walaupun `webhook_url` sudah diisi. **Jenis event** | Event | Pemicu | Arti | | --- | --- | --- | | `payment.awaiting_confirmation` | Pelanggan menekan Sudah Bayar di halaman bayar atau widget (manual saja). | Pelanggan mengaku sudah bayar. Cek dana, lalu verifikasi. | | `payment.confirmed` | Anda memverifikasi lewat dashboard atau API, atau server gateway selesai mencocokkan pembayaran. | Pembayaran sah. Untuk gateway lihat [Webhook Gateway](#gateway-webhooks). | Tidak ada event untuk kedaluwarsa, pembatalan, atau refund. Pakai `expires_at` dan `GET /api/qris/orders/:orderId` untuk itu. **Payload manual** ```json { "event": "payment.awaiting_confirmation", "idempotency_key": "a3f1c2d4-5b6e-4f70-8a9b-0c1d2e3f4a5b", "timestamp": "2026-10-17T09:00:00.000Z", "data": { "order_id": "INV-2026-001", "link_code": "ABC12345XYZW", "amount": 75000, "description": "Pembayaran Order #12345", "status": "awaiting_confirmation", "paid_at": null, "created_at": "2026-10-17T08:55:00.000Z", "mode": "live" } } ``` | Field | Arti | | --- | --- | | `idempotency_key` | ID unik pengiriman event ini. Sama di setiap percobaan ulang. Simpan untuk membuang duplikat. | | `timestamp` | Waktu payload dibuat (UTC). | | `data.amount` | Nominal dasar, **tanpa** fee. Untuk total, pakai catatan order Anda. | | `data.paid_at` | Terisi pada `payment.confirmed`. | | `data.mode` | Selalu `live` untuk pembayaran sungguhan. | **Header** | Header | Isi | | --- | --- | | `Content-Type` | `application/json` | | `X-TemanQRIS-Signature` | `sha256=`, HMAC-SHA256 dari raw body dengan webhook secret Anda. | | `X-TemanQRIS-Event` | Nama event, misalnya `payment.awaiting_confirmation`. | | `X-TemanQRIS-Idempotency-Key` | Sama dengan `idempotency_key` di body. | | `X-TemanQRIS-Retry` | Nomor percobaan ulang (`1`, `2`, `3`). Pengiriman pertama webhook gateway berisi `0`. Pengiriman pertama webhook manual tidak membawa header ini. | | `X-TemanQRIS-Mode` | `live` untuk pembayaran sungguhan, `sandbox` untuk uji coba. | | `User-Agent` | `TemanQRIS-Webhook/1.0` | **Aturan pengiriman** - Balas dengan status **2xx** setelah data tersimpan. Status lain, timeout, atau redirect (3xx) dianggap gagal. Redirect tidak diikuti. - Batas waktu **10 detik** per percobaan, dihitung dari awal request sampai response selesai. - Gagal? Dikirim ulang sampai 3 kali dengan jeda 30 detik, 2 menit, lalu 10 menit (total 4 percobaan). Payload dan `idempotency_key` tetap sama. - Webhook manual dikirim langsung saat status berubah. Webhook gateway masuk antrean dan biasanya tiba dalam sekitar satu menit. - `webhook_url` harus mengarah ke alamat publik. Alamat private, loopback, link-local, dan range khusus lain ditolak, baik saat URL didaftarkan maupun saat setiap pengiriman (termasuk perlindungan DNS rebinding). Di production wajib HTTPS. - Webhook bisa datang lebih dari sekali atau tidak berurutan. Selalu buang duplikat dengan `idempotency_key` dan jangan turunkan status yang sudah `paid`. --- ## Verifikasi Signature URL: https://temanqris.com/docs#webhook-verify TemanQRIS menandatangani **byte body persis seperti yang dikirim**. Hitung HMAC dari raw body, bukan dari JSON yang sudah di-parse lalu diserialisasi ulang, karena urutan key dan spasi bisa berbeda. Bandingkan dengan fungsi waktu konstan. Langkah di setiap handler: 1. Baca raw body. 2. Hitung `sha256=` + HMAC-SHA256(raw body, webhook secret) dalam hex. 3. Bandingkan dengan `X-TemanQRIS-Signature` secara waktu konstan. Gagal: balas `401`. 4. Parse JSON. Di production, tolak kalau `X-TemanQRIS-Mode` atau `data.mode` bukan `live`. 5. Buang duplikat dengan `idempotency_key`. 6. Proses dalam transaksi, lalu balas 2xx. ```js const express = require('express'); const crypto = require('crypto'); const app = express(); function verifyTemanQRIS(rawBody, signature, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); // timingSafeEqual melempar error kalau panjangnya beda, jadi cek dulu. if (typeof signature !== 'string' || signature.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } // express.raw, bukan express.json: kita butuh body mentah. app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifyTemanQRIS(req.body, req.get('X-TemanQRIS-Signature'), process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); } const payload = JSON.parse(req.body.toString('utf8')); if (req.get('X-TemanQRIS-Mode') !== 'live' || payload.data.mode !== 'live') { return res.status(400).json({ error: 'Not a live webhook' }); } // Buang duplikat dengan payload.idempotency_key, lalu update order dalam transaksi. if (payload.event === 'payment.confirmed') { // tandai order lunas } res.sendStatus(204); }); ``` ```php 'Invalid signature']); exit; } $payload = json_decode($raw, true); if (($_SERVER['HTTP_X_TEMANQRIS_MODE'] ?? '') !== 'live' || ($payload['data']['mode'] ?? '') !== 'live') { http_response_code(400); exit; } // Buang duplikat dengan $payload['idempotency_key'], lalu update order dalam transaksi. if ($payload['event'] === 'payment.confirmed') { // tandai order lunas } http_response_code(204); ``` ```python import hashlib import hmac import json import os from flask import Flask, abort, request app = Flask(__name__) @app.post("/webhooks/temanqris") def temanqris_webhook(): raw = request.get_data() # raw bytes expected = "sha256=" + hmac.new( os.environ["TEMANQRIS_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected, request.headers.get("X-TemanQRIS-Signature", "")): abort(401) payload = json.loads(raw) if request.headers.get("X-TemanQRIS-Mode") != "live" or payload["data"].get("mode") != "live": abort(400) # Buang duplikat dengan payload["idempotency_key"], lalu update order dalam transaksi. return "", 204 ``` Laravel: pakai `$request->getContent()` untuk raw body dan `$request->header('X-TemanQRIS-Signature')`. Kecualikan route webhook dari middleware CSRF. > **Jangan tandai lunas dari sumber lain.** `callback_url`, parameter redirect widget, dan endpoint publik Sudah Bayar bisa dipicu siapa saja. Satu-satunya bukti adalah webhook bertanda tangan atau `GET /api/qris/orders/:orderId` dari backend Anda. --- ## Webhook Gateway dan Callback URL: https://temanqris.com/docs#gateway-webhooks Gateway memakai sistem webhook yang sama untuk mengirim pembayaran yang sudah dikonfirmasi server. **Alur event gateway** 1. **Terbitkan invoice.** `POST /api/payment-gateway/payments` menerbitkan QR dan menyimpan invoice. 2. **Terima pembayaran.** Pelanggan membayar lewat `payment_link.url` atau `payment_link.qr_image`. 3. **Rekonsiliasi server.** Worker mencocokkan total nominal dan mengunci klaim transaksi di database. 4. **Kredit dan antrean webhook.** Dalam satu transaksi, wallet Anda dikreditkan `net_amount` (saldo tertahan) dan webhook `payment.confirmed` dimasukkan ke antrean. Kalau kredit gagal, webhook juga tidak ada. 5. **Kirim webhook.** Biasanya dalam sekitar satu menit ke `webhook_url`. 6. **Arahkan pelanggan.** Pelanggan dapat diarahkan ke `callback_url`, tapi callback bukan bukti pembayaran. **Payload `payment.confirmed` gateway** ```json { "event": "payment.confirmed", "idempotency_key": "8f4e2b1a-6c3d-4e5f-9a0b-1c2d3e4f5a6b", "timestamp": "2026-10-17T03:08:30.000Z", "data": { "order_id": "INV-2026-0001", "link_code": "aZ3kQ9mP2xLw", "amount": 150000, "description": "Pesanan pelanggan INV-2026-0001", "status": "paid", "paid_at": "2026-10-17T03:08:12.000Z", "created_at": "2026-10-17T03:00:00.000Z", "mode": "live", "payment_mode": "gateway", "payment_attempt_id": "2de4a4d5-3b0e-4b7a-9a3e-4c9f7a2b1e55", "base_amount": 150000, "fee_amount": 0, "unique_code": 321, "total_amount": 150321, "provider_fee_amount": 1200, "net_amount": 148800, "confirmed_by": "gateway" } } ``` | Field | Arti | | --- | --- | | `total_amount` | `base_amount + fee_amount + unique_code`. Cocokkan dengan `totalAmount` yang Anda simpan saat membuat invoice. | | `net_amount` | `base_amount + fee_amount - provider_fee_amount`. Yang dikreditkan ke wallet Anda (saldo tertahan sampai 00.00 WIB hari berikutnya). | | `provider_fee_amount` | Biaya layanan gateway TemanQRIS untuk invoice ini. | | `payment_attempt_id` | Sama dengan response create. | | `paid_at` | Waktu pembayaran. Kalau setelah `expiresAt` invoice, pelanggan membayar dalam masa tenggang. Payload live tidak punya field `paid_late`. | | `confirmed_by` | `gateway`, baik untuk pembayaran yang dicocokkan otomatis maupun yang diselesaikan admin. Untuk membedakan jalur, tetap pakai `payment_mode`. | **Header** | Header | Fungsi | | --- | --- | | `X-TemanQRIS-Signature` | HMAC-SHA256 dalam format `sha256=`. | | `X-TemanQRIS-Event` | Nama event, untuk gateway biasanya `payment.confirmed`. | | `X-TemanQRIS-Idempotency-Key` | Kunci pengiriman. Simpan untuk membuang retry ganda. | | `X-TemanQRIS-Retry` | `0` di pengiriman pertama, `1` sampai `3` di pengiriman ulang. | | `X-TemanQRIS-Mode` | `live` untuk pembayaran sungguhan, `sandbox` untuk uji coba. Handler production harus menolak `sandbox`. Payload live juga membawa `data.mode: "live"`. | **Handler gateway (Node.js)** ```js const crypto = require('crypto'); function verifyTemanQRIS(rawBody, signature, webhookSecret) { const expected = 'sha256=' + crypto .createHmac('sha256', webhookSecret) .update(rawBody) .digest('hex'); if (typeof signature !== 'string' || signature.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } app.post('/webhooks/temanqris', express.raw({ type: 'application/json' }), async (req, res) => { const signature = req.headers['x-temanqris-signature']; if (!verifyTemanQRIS(req.body, signature, process.env.TEMANQRIS_WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); } const payload = JSON.parse(req.body.toString('utf8')); // Production hanya menerima pengiriman live. if (req.headers['x-temanqris-mode'] !== 'live' || payload.data.mode !== 'live') { return res.status(400).json({ error: 'Not a live webhook' }); } if (payload.event === 'payment.confirmed' && payload.data.payment_mode === 'gateway') { // Buang duplikat dengan payload.idempotency_key, cocokkan total_amount, // lalu update order dalam transaksi. } return res.sendStatus(204); }); ``` **Handler gateway (PHP)** ```php **Tips.** Balas 2xx setelah data tersimpan dengan aman. Pengiriman gagal diulang dengan aturan di atas (3 kali, batas waktu 10 detik). Kalau semua percobaan gagal, admin TemanQRIS diberi tahu, tapi jangan bergantung pada itu: jalankan polling cadangan `GET /api/qris/orders/:orderId`. Jangan pernah menandai order lunas dari callback tanpa tanda tangan atau dari endpoint klaim pelanggan. --- ## Widget.js URL: https://temanqris.com/docs#widget Pasang tombol pembayaran QRIS di website Anda dengan satu baris kode, tanpa backend dan tanpa API key. Widget menampilkan modal pembayaran yang responsif. **Pemasangan** ```html ``` > **Mendapatkan Merchant ID.** Login ke [Dashboard > Widget](/dashboard/widget) untuk melihat Merchant ID Anda. Tombol "Bayar dengan QRIS" muncul di tempat script dipasang: **Cara kerja widget** 1. Pelanggan menekan tombol. Kalau `data-amount` kosong, pelanggan mengisi nominal. 2. Widget membuat QR dinamis dari QRIS statis Anda dan sebuah order dengan `order_id` berawalan `ORD-W`. QR berlaku 60 menit. 3. Pelanggan membayar, lalu menekan tombol konfirmasi di widget. 4. Order menjadi `awaiting_confirmation` dan webhook `payment.awaiting_confirmation` dikirim ke `data-webhook`. 5. Pelanggan diarahkan ke `data-callback` (kalau diisi) atau melihat pesan sukses. 6. Anda memverifikasi lewat dashboard atau `POST /api/qris/orders/:orderId/verify`. Webhook `payment.confirmed` dikirim. Widget selalu memakai jalur manual. Dana masuk ke QRIS Anda sendiri. **Batas** - Setiap QR widget dihitung terhadap batas harian tier Anda (dicatat sebagai `widget_generate`). - 60 QR per menit per IP pengunjung, dan 30 konfirmasi per menit per IP. --- ## Opsi Widget URL: https://temanqris.com/docs#widget-options Atur widget lewat atribut `data-*`: | Atribut | Tipe | Keterangan | | --- | --- | --- | | `data-merchant` | string, wajib | Merchant ID Anda. | | `data-amount` | integer | Nominal tetap dalam rupiah, bilangan bulat. Kalau kosong, pelanggan mengisi sendiri. | | `data-button-text` | string | Teks tombol. Bawaan: "Bayar dengan QRIS". | | `data-button-color` | string | Warna tombol dalam hex (`#22c55e`) atau nama warna CSS (`green`). Nilai lain kembali ke bawaan `#ff6b35`. | | `data-description` | string | Deskripsi pembayaran (opsional, maksimal 255 karakter). | | `data-callback` | string (URL) | **Callback URL.** Browser pelanggan diarahkan ke sini setelah menekan konfirmasi, dengan query `order_id`, `amount`, dan `status`. | | `data-webhook` | string (URL) | **Webhook URL.** Notifikasi server ke server saat status berubah, untuk update database. Harus URL publik (HTTPS). | **Contoh lengkap dengan callback dan webhook** ```html ``` > **Callback URL (`data-callback`).** Pelanggan diarahkan ke: > `https://yoursite.com/thank-you?order_id=ORD-W5E1A9C03B7D2F4E6&amount=50000&status=confirmed` > Nilai `status=confirmed` diisi oleh browser pelanggan dan hanya berarti pelanggan menekan tombol konfirmasi. **Bukan bukti bayar.** Cek status lewat webhook atau `GET /api/qris/orders/:orderId` dari backend. > **Webhook URL (`data-webhook`).** Server TemanQRIS mengirim POST ke URL Anda dengan payload standar: > `{ "event": "payment.awaiting_confirmation", "idempotency_key": "...", "timestamp": "...", "data": { "order_id": "ORD-W...", "amount": 50000, ... } }` > Webhook hanya dikirim kalau Anda sudah membuat webhook secret. Lihat [Verifikasi Signature](#webhook-verify). > **Catatan keamanan.** Merchant ID memang publik (ada di HTML halaman Anda) dan bukan rahasia. Siapa pun bisa membuat QR atas nama Anda dengan nominal apa pun, tapi dana selalu masuk ke QRIS Anda. Karena itu selalu cocokkan `data.amount` di webhook dengan harga yang Anda harapkan, dan jangan pernah menandai lunas dari redirect. --- ## Widget API URL: https://temanqris.com/docs#widget-api Endpoint publik yang dipakai `widget.js`. Bisa dipanggil langsung kalau Anda membuat tampilan pembayaran sendiri di browser tanpa API key. CORS terbuka untuk semua origin. #### Buat QR widget `POST /api/widget/generate` (publik, 60 request per menit per IP) | Field | Tipe | Aturan | | --- | --- | --- | | `merchant_id` | string, wajib | Merchant ID, maksimal 64 karakter. | | `amount` | integer, wajib | Manual: 1 sampai 100.000.000. Gateway: maksimal 1.000.000. | | `description` | string, opsional | Maksimal 255 karakter. | | `webhook_url` | string, opsional | URL publik. | | `callback_url` | string, opsional | URL http(s). | | `payment_mode` | string, opsional | Kosongkan untuk manual. Isi `"gateway"` untuk pembayaran otomatis (butuh KYC disetujui). | | `idempotency_key` | string, opsional | Hanya untuk `gateway`, maksimal 256 karakter. Key yang sama mengembalikan invoice yang sama. Tanpa key, setiap panggilan membuat invoice baru. | ```bash curl -X POST https://temanqris.com/api/widget/generate \ -H "Content-Type: application/json" \ -d '{ "merchant_id": "abc123xyz", "amount": 50000, "description": "Pembayaran Order #123", "webhook_url": "https://yoursite.com/webhook", "callback_url": "https://yoursite.com/thank-you" }' ``` ```js // Dari browser, tanpa API key const res = await fetch('https://temanqris.com/api/widget/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ merchant_id: 'abc123xyz', amount: 50000, payment_mode: 'gateway', idempotency_key: `cart-${cartId}` }) }); const invoice = await res.json(); document.querySelector('#qr').src = invoice.qr_image; ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['merchant_id' => 'abc123xyz', 'amount' => 50000]), ]); $invoice = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python invoice = requests.post( "https://temanqris.com/api/widget/generate", json={"merchant_id": "abc123xyz", "amount": 50000}, timeout=15, ).json() ``` **Response manual `200 OK`** ```json { "success": true, "order_id": "ORD-W5E1A9C03B7D2F4E6", "link_code": "W8F3A1C7E92B4D605", "amount": 50000, "merchant_name": "TOKO EXAMPLE", "qr_image": "data:image/png;base64,iVBOR...", "qris_string": "00020101021226...", "expires_at": "2026-10-17T04:00:00.000Z", "description": "Pembayaran Order #123" } ``` **Response gateway `201 Created` (atau `200` untuk key yang sama)** ```json { "success": true, "payment_mode": "gateway", "order_id": "ORD-W3F9A1C7E92B4D605A1B2", "link_code": "Qm7tR2xP9aKd", "amount": 50642, "base_amount": 50000, "unique_code": 642, "total_amount": 50642, "merchant_name": "TemanQRIS", "qr_image": "data:image/png;base64,iVBOR...", "qris_string": "00020101021226...", "expires_at": "2026-10-17T03:15:00.000Z", "description": "Pembayaran via Widget" } ``` Untuk gateway, `amount` adalah total yang harus dibayar (sudah termasuk kode unik). Biaya layanan tidak ditampilkan ke pelanggan, Anda melihatnya di webhook dan dashboard. `order_id` dibuat dari `idempotency_key`. **Error** | Status | Body | | --- | --- | | 400 | `{"error":"merchant_id is required"}`, `{"error":"amount must be a whole number between 1 and 100000000"}`, `{"error":"Gateway amount must not exceed Rp1.000.000"}`, `{"error":"idempotency_key must be at most 256 characters"}` | | 400 | `{"error":"This amount is too small for an automatic payment. Use a larger amount.","code":"amount_too_small"}` | | 400 / 503 | `{"error":"webhook_url rejected: ...","code":"invalid_webhook_url"}` atau `webhook_dns_unavailable` | | 400 | `{"error":"webhook_url must use the website or app domain from your approved KYC","code":"webhook_host_mismatch"}` atau `callback_host_mismatch` (gateway saja: `webhook_url` dan `callback_url` harus memakai domain website/aplikasi di KYC Anda yang sudah disetujui, atau subdomainnya. Mode manual bebas) | | 403 | `KYC_REQUIRED` (gateway) | | 403 | `{"error":"Merchant ini sudah mencapai batas transaksi harian. Coba lagi besok.","code":"new_account_daily_amount_cap"}` (gateway, hanya kalau batas nominal akun baru diaktifkan admin) | | 404 | `{"error":"Merchant not available"}` (merchant tidak ada, nonaktif, atau belum mengatur QRIS) | | 409 | `{"error":"This payment attempt already failed or expired. Use a new idempotency_key to try again.","code":"gateway_attempt_terminal"}` | | 429 | `{"error":"Daily limit exceeded for this merchant"}` (mode manual) atau batas per IP | | 503 | `{"error":"Payment gateway mode is disabled by admin"}` | #### Konfirmasi dari widget `POST /api/widget/confirm/:orderId` (publik, 30 request per menit per IP) Sama seperti tombol Sudah Bayar: order menjadi `awaiting_confirmation`. Kirim `link_code` di body supaya order teridentifikasi tepat. ```bash curl -X POST https://temanqris.com/api/widget/confirm/ORD-W5E1A9C03B7D2F4E6 \ -H "Content-Type: application/json" \ -d '{ "link_code": "W8F3A1C7E92B4D605" }' ``` ```js await fetch(`https://temanqris.com/api/widget/confirm/${encodeURIComponent(orderId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ link_code: linkCode }) }); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['link_code' => $linkCode]), ]); $out = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python out = requests.post( f"https://temanqris.com/api/widget/confirm/{order_id}", json={"link_code": link_code}, timeout=15, ).json() ``` **Response `200 OK`** ```json { "success": true, "message": "Konfirmasi berhasil. Merchant akan segera memproses pesanan Anda.", "status": "awaiting_confirmation" } ``` Konfirmasi ulang menjawab `200` dengan `"message": "Pesanan sudah dikonfirmasi sebelumnya."`. Order gateway menjawab `202` dengan `payment_mode: "gateway"` tanpa perubahan. Error: `404 Order ID tidak ditemukan`, `400` kalau link kedaluwarsa atau status lain, `409` kalau order tidak bisa diidentifikasi unik (kirim `link_code`). #### Info merchant `GET /api/widget/merchant/:merchantId` (publik) ```bash curl https://temanqris.com/api/widget/merchant/abc123xyz ``` ```js const { merchant_name } = await (await fetch('https://temanqris.com/api/widget/merchant/abc123xyz')).json(); ``` ```php true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('TEMANQRIS_API_KEY')], ]); $usage = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```python usage = requests.get( "https://temanqris.com/api/qris/usage", headers={"X-API-Key": os.environ["TEMANQRIS_API_KEY"]}, timeout=15, ).json() ``` **Response `200 OK`** ```json { "tier": "free", "daily_limit": 50, "usage": { "today": { "total": 15, "remaining": 35, "by_endpoint": [ { "endpoint": "/api/qris/generate", "request_count": 10 }, { "endpoint": "/api/qris/payment-link", "request_count": 4 }, { "endpoint": "/api/qris/orders/:orderId/verify", "request_count": 1 } ] }, "this_month": { "total": 250, "by_endpoint": [ { "endpoint": "/api/qris/generate", "total_requests": "180" }, { "endpoint": "/api/qris/payment-link", "total_requests": "70" } ] } } } ``` - Hari dihitung per tanggal UTC (reset 07.00 WIB). Bulan dimulai tanggal 1. - `endpoint` berisi pola route, misalnya `/api/qris/orders/:orderId/verify`. QR dari widget tercatat sebagai `widget_generate`. - `remaining` diambil dari penghitung kuota harian, sama dengan yang dipakai header `X-RateLimit-Remaining`. - Pembayaran gateway live tercatat di rincian sebagai `/api/payment-gateway/payments` untuk statistik, tapi tidak memotong kuota harian. Jadi `total` bisa lebih besar dari jumlah request yang memakai kuota, sementara `remaining` tetap akurat. - Pemakaian Sandbox tidak masuk ke sini. Lihat kartu pemakaian di console Sandbox. --- ## Changelog URL: https://temanqris.com/docs#changelog ### v1.3.4 (2026-11-01) **Berubah** - **Biaya layanan gateway 0,8% untuk semua nominal.** Dihitung dari `amount` dan dibulatkan ke atas ke rupiah penuh. Tarif khusus untuk nominal di bawah Rp100.000 tidak berlaku lagi. Di `GET /api/payment-gateway/status`, `percent_bps` dan `below_threshold_percent_bps` bernilai `80`, `threshold_amount` `1`, dan `flat_amount` `0`. Invoice yang terbit sebelum perubahan tetap memakai tarif lamanya. - **QR gateway berlaku 1 jam secara bawaan** dan bisa diatur admin. Masa berlaku yang berlaku untuk invoice ada di `expiresAt`. - **Field `payable_until`** di response create gateway (`payment_link.payable_until`) dan `GET /api/qris/orders/:orderId`. Isinya batas terakhir pembayaran yang masih dikreditkan otomatis, jadi integrasi Anda tidak perlu menebak masa tenggang. `null` untuk order manual, lunas, atau dibatalkan. - **Panduan pembatalan saat masa tenggang.** Kalau pembeli batal sebelum membayar, batalkan order lewat `POST /api/qris/orders/:orderId/cancel`, walaupun QR sudah kedaluwarsa. Tanpa itu, pembayaran di masa tenggang tetap dikreditkan. ### v1.3.3 (2026-10-28) **Berubah** - **Biaya layanan gateway di bawah Rp100.000 jadi 0,6% mulai 1 November 2026.** Mengikuti perubahan aturan dari penyedia (PJP) TemanQRIS. Sampai 31 Oktober 2026 biayanya Rp125 per transaksi. Mulai Minggu, 1 November 2026 pukul 00.00 WIB biayanya 0,6% dari `amount`, dibulatkan ke atas ke rupiah penuh. Rp100.000 ke atas tetap 0,9%. Invoice yang terbit sebelum tanggal itu tetap memakai biaya saat dibuat. Biaya tetap dipotong dari dana merchant, nominal yang dibayar pelanggan tidak berubah. **Baru** - **Field `below_threshold_percent_bps`** di `provider_fee` pada `GET /api/payment-gateway/status`. Biaya di bawah `threshold_amount` adalah `flat_amount` ditambah persen ini dari nominal dasar. Client lama yang hanya membaca `flat_amount` tetap jalan, tapi sebaiknya ikut membaca field ini supaya hitungan biaya di sisi Anda tetap tepat setelah 1 November 2026. ### v1.3.2 (2026-10-18) **Baru** - **Batas pembuatan invoice per menit.** Bawaan 60 invoice gateway live per merchant per menit, bisa diubah admin. Lewat batas: `429` dengan `code: "gateway_rate_limited"`, `limit`, `window_seconds`, `retry_after_seconds`, dan header `Retry-After`. - **Batas nominal harian untuk akun yang baru lolos KYC.** Tidak aktif secara bawaan. Kalau diaktifkan admin, total `amount` invoice gateway live per hari dibatasi selama 14 hari pertama setelah KYC disetujui. Lewat batas: `403` dengan `code: "new_account_daily_amount_cap"`, `limit`, `used`, dan `resetTime`. Replay dengan `Idempotency-Key` yang sama tidak dihitung lagi. Berlaku juga untuk Widget API mode gateway. **Berubah** - **Pembayaran gateway live tidak lagi memakai kuota harian.** `POST /api/payment-gateway/payments` dengan key live atau JWT dashboard tidak memotong kuota 50 (Free) atau 1.000 (Premium) per hari. Kuota itu tetap berlaku untuk fitur QRIS manual. Panggilan gateway tetap tercatat di statistik `GET /api/qris/usage`. Key `tq_test_` tetap memakai kuota Sandbox. - **Header `X-RateLimit-*` di create gateway live** sekarang menggambarkan jendela per menit, bukan kuota harian. `X-RateLimit-Reset` menunjuk awal menit berikutnya. ### v1.3.1 (2026-10-17) **Baru** - **Mode Sandbox untuk Payment Gateway.** Key `tq_test_` di endpoint yang sama, response dengan `"mode": "sandbox"`, QR yang sengaja tidak bisa dibayar, halaman bayar `/p/sb_...`, simulasi `paid`, `expired`, dan `late_paid` lewat `POST /api/payment-gateway/sandbox/payments/:order_id/simulate`, cek invoice lewat `GET /api/payment-gateway/sandbox/payments/:order_id`, webhook bertanda tangan dengan secret terpisah `whsec_test_`, kuota sendiri 500 per hari, tanpa KYC, data dihapus otomatis setelah 30 hari. - **Header `X-TemanQRIS-Mode` dan `data.mode`** di setiap webhook. Live selalu `live`, sandbox selalu `sandbox`. Handler production harus menolak sandbox. - **Biaya layanan gateway.** 0,9% dari nominal dasar mulai Rp100.000 (dibulatkan ke atas), Rp125 di bawahnya. Dipotong dari dana merchant, bukan dari pelanggan. Field baru `provider_fee_amount` dan `net_amount` di response create, webhook, daftar link, dan cek order. Tarif terbaru ada di `provider_fee` pada `GET /api/payment-gateway/status`. Error baru `amount_below_provider_fee`. - **Saldo tertahan H+1.** Dana gateway bisa ditarik mulai 00.00 WIB hari berikutnya. - **Refund dan reversal.** Refund di sisi penyedia dicatat dan ditinjau admin. Reversal mengambil dari saldo tertahan dulu, lalu saldo tersedia, tidak pernah melebihi dana yang dikreditkan. - **Masa tenggang bayar telat.** Pembayaran sampai 60 menit (bawaan) setelah QR kedaluwarsa tetap dicocokkan dan dikreditkan. - **Status KYC di `GET /api/payment-gateway/status`.** Field `kyc_status` dan `gateway_access`. Gateway live tanpa KYC ditolak `403 KYC_REQUIRED`. - **Kode error bertipe.** Setiap error gateway yang aman ditampilkan membawa `code` string. Error internal selalu dijawab 500 generik tanpa detail. - **Halaman Kode Error dan Changelog** di dokumentasi, plus contoh Node.js, PHP, dan Python untuk setiap endpoint. **Berubah** - **Biaya tambahan (`fee_amount`) dikreditkan ke merchant.** Biaya tambahan ditetapkan merchant sendiri, dibayar pelanggan, masuk penuh ke saldo Anda, bersama `amount`. Hanya kode unik dan biaya layanan yang menjadi pendapatan TemanQRIS. - **`fee_amount` tidak boleh melebihi `amount`** (`fee_exceeds_base`). - **Kode unik 0 sampai 999** dipilih dari total yang masih bebas di seluruh platform, sehingga dua invoice aktif tidak pernah punya total yang sama. Kalau semua kode untuk satu nominal sedang dipakai: `503 gateway_amount_busy`. - **Idempotency lebih ketat.** Key disimpan sebagai hash SHA-256 dari akun, `order_id`, dan key. Isi request diingat: key yang sama dengan isi berbeda ditolak `422 idempotency_key_reused`. Satu `order_id` hanya untuk satu pembayaran gateway (`409 gateway_order_id_used`). Request yang masih diproses: `409 gateway_creation_in_progress`. - **`webhook_url` hanya alamat publik.** Alamat private, loopback, link-local, dan range khusus ditolak saat didaftarkan dan saat setiap pengiriman. Gangguan DNS sementara dijawab `503 webhook_dns_unavailable` dan aman diulang. - **Webhook gateway dijamin ada.** Antrean webhook ditulis dalam transaksi yang sama dengan kredit wallet. Setiap percobaan dibatasi 10 detik. - **Pembatalan order gateway** menghentikan pencocokan otomatis. Pembayaran yang masuk setelahnya ditinjau admin. - **Batas invoice terbuka** hanya menghitung invoice yang masih berlaku. - **Kuota harian** dihitung tanpa kunci database, reset 00.00 UTC (07.00 WIB). Hanya error 5xx yang dikembalikan ke kuota. - **Arsip payment link** (`DELETE /api/qris/payment-link/:id`) menjawab `{"success": true, "message": "Payment link archived"}` dan menolak link yang sudah dibayar (`409`). - **API key disimpan dalam bentuk hash** untuk pencarian dan terenkripsi untuk ditampilkan ulang. Alur tampilkan key dengan password tidak berubah. - **Upload QRIS** menolak gambar lebih dari 6000 px atau 16 megapiksel sebelum diproses. - **Rincian `GET /api/qris/usage`** memakai pola route (misalnya `/api/qris/orders/:orderId/verify`). - **Response API yang butuh login** diberi header `Cache-Control: no-store`. **Diperbaiki** - **`POST /api/qris/generate` tidak lagi mengubah order lunas.** Request yang sama persis untuk order `paid` atau `awaiting_confirmation` mengembalikan order itu apa adanya dengan `existing: true`. Nominal berbeda ditolak `409`. - **`POST /api/qris/payment-link` hanya memakai ulang link milik `order_id` yang sama.** Tanpa `order_id`, yang dipakai ulang hanya link yang juga tanpa `order_id`. - **Upload gambar dengan tipe yang tidak didukung** (misalnya WebP) dijawab `400`, bukan `500`. - **Filter `status` di `GET /api/qris/orders` berlaku untuk semua status**, termasuk `awaiting_confirmation`. Nilai yang tidak dikenal ditolak `400`. - **`POST /api/qris/generate` saat DNS bermasalah** menjawab `503` dengan `code: "webhook_dns_unavailable"`, sama seperti gateway. - **`503 gateway_amount_reservation_lost`** sekarang meminta `order_id` baru, karena `order_id` lama sudah terpakai. - **Webhook settlement otomatis** membawa `confirmed_by: "gateway"`. ### v1.3.0 (2026-08-15) - **Payment Gateway otomatis.** Buat QRIS gateway dengan idempotency, nominal dasar sampai Rp1.000.000, total sampai Rp1.000.999, dan webhook bertanda tangan. - Dokumentasi publik `/docs` untuk endpoint create dan status Payment Gateway. - Contoh request dan response lengkap untuk kontrak gateway yang tidak bergantung pada penyedia: `payment_attempt_id`, kode unik, idempotency, checkout lewat payment link, dan status gateway. - Dokumentasi webhook settlement gateway bertanda tangan dengan `payment.confirmed`, field merchant yang netral terhadap penyedia, verifikasi HMAC, header retry, dan aturan keamanan callback. Metadata transaksi penyedia tetap internal. - Field operasional penyedia dihapus dari contoh API, webhook, dan dokumentasi publik. - Sesi penyedia tersimpan terenkripsi di database dan cursor routing yang tahan multi-worker. - Halaman gateway di dashboard diselaraskan dengan QRIS Generator dan memakai idempotency key yang stabil untuk retry. - Navigasi dokumentasi bisa dicari, dengan menu hamburger untuk layar kecil. - Contoh API lengkap (Full API Examples) untuk alur backend.