PayQris
Dokumentasi API
PayQris menerbitkan QRIS dinamis dari QRIS merchant Anda sendiri. Uangnya masuk langsung ke rekening merchant — PayQris tidak pernah menampung dana.
Sebelum mulai
PayQris menumpang QRIS yang sudah Anda miliki — ia tidak mendaftarkan Anda ke Gojek dan tidak menerbitkan QRIS baru. Tiga hal ini harus sudah ada:
- 1Akun pedagang — GoPay Merchant / GoBiz. Dua nama itu merujuk ke akun yang sama: GoBiz adalah aplikasi pedagangnya, GoPay Merchant nama yang lebih dikenal. Yang dipakai PayQris adalah email dan kata sandi yang Anda gunakan untuk masuk ke sana.
- 2Minimal satu merchant di akun tersebut. Kalau akunnya belum punya merchant, penyambungan ditolak dengan pesan
Akun GoBiz tidak memiliki merchant. - 3QRIS statis merchant itu. Teks panjang berawalan
00020101..., disalin dari QRIS merchant Anda. Ini yang jadi dasar setiap QRIS dinamis yang diterbitkan PayQris.
Akun GoPay biasa tidak bisa dipakai
Akun GoPay yang Anda pakai berbelanja adalah akun pembeli — akun itu tidak punya merchant, sehingga penyambungan akan ditolak. Yang dibutuhkan akun pedagang: usaha Anda sudah terdaftar dan QRIS-nya sudah terbit. Kalau belum, daftarkan usaha Anda lebih dulu lewat GoBiz sebelum kembali ke sini.
Sudah punya ketiganya? Masuk ke dashboard → tab Koneksi, isi email dan kata sandi GoBiz, lalu tempelkan QRIS statisnya. Cukup sekali.
Uang tidak lewat PayQris
Pembayaran masuk langsung ke rekening merchant Anda sendiri, karena QRIS yang dipakai memang milik Anda. PayQris hanya menerbitkan nominal unik dan mencocokkan pelunasannya — bukan penampung dana.
Cara kerjanya
- 1Hubungkan akun GoBiz. Sekali saja, lewat dashboard. PayQris memakai QRIS statis merchant Anda sebagai dasar.
- 2Buat tagihan. Aplikasi Anda memanggil
POST /v1/pay/create. PayQris menambahkan kode unik pada nominal, lalu mengembalikan QRIS dinamis. - 3Pelanggan membayar. Yang dibayar adalah
total_amount, bukanamount. - 4PayQris memberi tahu. Begitu pelunasan tercocokkan, webhook
payment.paiddikirim ke aplikasi Anda.
Kode unik menentukan segalanya
Jalur ini tidak punya nomor transaksi dari penyedia, jadi pelunasan dicocokkan dari nominal yang persis. Minta pelanggan membayar total_amount (= amount + unique_code). Kalau yang dibayar dibulatkan atau dikurangi, pembayaran tidak akan pernah tercocokkan.
Istilah yang mudah tertukar
| Istilah | Artinya di PayQris |
|---|---|
| Merchant | Akun usaha Anda di PayQris. SATU saja, walau aplikasinya banyak. |
| API key | Kredensial per aplikasi. Satu aplikasi satu kunci, dibuat di tab Aplikasi. |
| Webhook URL | Alamat di server ANDA. PayQris mengirim notifikasi ke sana. |
| Webhook secret | Kunci untuk memverifikasi bahwa notifikasi itu memang dari PayQris. |
| invoice_id | ID tagihan dari PayQris, berbentuk UUID. |
| merchant_order_id | Nomor pesanan dari sistem Anda sendiri. Opsional tapi dianjurkan. |
Aplikasi kedua? Tambah API key, jangan tambah merchant.
Semua aplikasi Anda memakai satu merchant yang sama. Membuat merchant kedua berarti dua pengalokasi kode unik bekerja pada QRIS yang sama tanpa saling tahu — dan cepat atau lambat keduanya menerbitkan nominal yang kembar, sehingga satu pembayaran mencocoki dua tagihan.
Autentikasi
Buat API key di dashboard → tab Aplikasi. Kunci hanya ditampilkan sekali. Satu kunci untuk satu aplikasi, supaya webhook dan jejak auditnya terpisah.
Authorization: Bearer pq_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/jsonBase URL: https://api.payqris.com
Membuat tagihan
POST /v1/pay/create| Field | Tipe | Wajib | Keterangan |
|---|---|---|---|
| amount | integer | ya | Nominal dasar, 100 – 10.000.000 |
| merchant_order_id | string | tidak | Nomor pesanan Anda, maks 80 karakter |
| customer_name | string | tidak | Maks 120 karakter |
| expires_in_minutes | integer | tidak | Masa berlaku 3 – 1440 menit |
Contoh
curl -X POST https://api.payqris.com/v1/pay/create \
-H "Authorization: Bearer pq_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"amount": 15000,
"merchant_order_id": "INV-2026-0001",
"customer_name": "Budi",
"expires_in_minutes": 30
}'Balasan
{
"invoice_id": "3f9a...-uuid",
"merchant_order_id": "INV-2026-0001",
"qris_payload": "00020101021226...",
"amount": 15000,
"unique_code": 51,
"total_amount": 15051,
"expired_at": "2026-09-06T08:30:00.000Z",
"status": "pending"
}Tampilkan qris_payload sebagai kode QR, dan tulis total_amount sebagai jumlah yang harus dibayar.
Mengecek status
GET /v1/pay/{invoice_id}/statusDipakai sebagai cadangan bila webhook tidak sampai — bukan pengganti webhook. Jangan dipanggil dalam gelung rapat; secukupnya saja saat halaman pembayaran dibuka.
{
"success": true,
"data": {
"invoice_id": "3f9a...-uuid",
"merchant_order_id": "INV-2026-0001",
"status": "paid",
"paid_at": "2026-09-06T08:12:44.000Z",
"payment_method": "gobiz_api",
"amount": 15000,
"unique_code": 51
}
}Nilai status: pending, paid, expired, canceled.
Webhook
Atur URL dan secret per aplikasi di dashboard → tab Aplikasi. Wajib HTTPS. Dikirim sekali saat pembayaran lunas.
POST <url webhook Anda>
X-Webhook-Signature: <hmac sha256 hex>
X-Signature: <sama, alias>
Content-Type: application/json
{
"event": "payment.paid",
"data": {
"invoice_id": "3f9a...-uuid",
"merchant_order_id": "INV-2026-0001",
"status": "paid",
"amount": 15000,
"unique_code": 51,
"total_amount": 15051,
"payment_method": "gobiz_api",
"funding_source": "",
"paid_at": "2026-09-06T08:12:44.000Z",
"customer_name": "Budi"
}
}Verifikasi tanda tangan
HMAC-SHA256 heksadesimal dari raw body, memakai secret webhook aplikasi itu. Hitung sebelum body diurai — JSON yang sudah di-parse lalu di-stringify ulang akan menghasilkan tanda tangan berbeda.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function sah(rawBody: string, signature: string, secret: string) {
const harap = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(harap);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}Percobaan ulang
Balas 2xx secepatnya. Selain itu dianggap gagal dan diulang 6 kali: 10 detik, 30 detik, 2 menit, 5 menit, 15 menit, lalu 30 menit. Setelah itu ditandai gagal dan bisa dikirim ulang manual dari dashboard.
Buat penanganan Anda idempoten
Satu tagihan bisa menerima webhook lebih dari sekali bila balasan Anda terlambat. Pakai invoice_id sebagai kunci, dan abaikan yang sudah pernah diproses.
Contoh kode
Membuat tagihan
▸PHP
<?php
$payload = json_encode([
'amount' => 25000,
'merchant_order_id' => 'ORDER-001',
'customer_name' => 'Budi',
]);
$ch = curl_init('https://api.payqris.com/v1/pay/create');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PAYQRIS_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $payload,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
// $data['qris_payload'] -> jadikan gambar QR
// $data['total_amount'] -> nominal yang HARUS dibayar pelanggan▸Laravel
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Http;
class PayqrisController extends Controller
{
public function create()
{
$response = Http::withToken(config('services.payqris.key'))
->post(config('services.payqris.base_url') . '/v1/pay/create', [
'amount' => 25000,
'merchant_order_id' => 'ORDER-001',
'customer_name' => 'Budi',
]);
return response()->json($response->json(), $response->status());
}
}▸Node.js / Express
app.post('/bayar', async (req, res) => {
const r = await fetch(process.env.PAYQRIS_BASE_URL + '/v1/pay/create', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYQRIS_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 25000,
merchant_order_id: 'ORDER-001',
customer_name: 'Budi',
}),
});
const data = await r.json();
return res.status(r.status).json(data);
});▸Next.js (Route Handler)
// app/api/bayar/route.ts
export async function POST(request: Request) {
const { amount, orderId } = await request.json();
// Kunci API HANYA dipakai di sisi server. Jangan pernah
// memanggil PayQris langsung dari komponen peramban.
const r = await fetch(process.env.PAYQRIS_BASE_URL + '/v1/pay/create', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYQRIS_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, merchant_order_id: orderId }),
cache: 'no-store',
});
return Response.json(await r.json(), { status: r.status });
}Menerima webhook
Selalu pakai raw body
Tanda tangan dihitung dari isi mentah permintaan. Kalau JSON diurai dulu lalu disusun ulang jadi teks, urutan kunci dan spasinya berubah — tanda tangannya ikut berubah dan semua webhook akan ditolak.
▸PHP
<?php
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$body = file_get_contents('php://input'); // raw, bukan $_POST
$expected = hash_hmac('sha256', $body, getenv('PAYQRIS_WEBHOOK_SECRET'));
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit(json_encode(['message' => 'Invalid signature']));
}
$payload = json_decode($body, true);
if (($payload['event'] ?? '') !== 'payment.paid') {
http_response_code(400);
exit(json_encode(['message' => 'Event tidak dikenal']));
}
$d = $payload['data'];
// Idempoten: lewati kalau invoice ini sudah pernah diproses.
// tandaiLunas($d['invoice_id'], $d['merchant_order_id']);
http_response_code(200);
echo json_encode(['ok' => true]);▸Laravel
<?php
use Illuminate\Http\Request;
public function webhook(Request $request)
{
$body = $request->getContent(); // raw body
$signature = $request->header('X-Webhook-Signature', '');
$expected = hash_hmac('sha256', $body, config('services.payqris.webhook_secret'));
if (!hash_equals($expected, $signature)) {
return response()->json(['message' => 'Invalid signature'], 401);
}
$data = $request->input('data');
// Idempoten: kunci pada $data['invoice_id'].
return response()->json(['ok' => true]);
}▸Node.js / Express
const crypto = require('node:crypto');
// express.raw WAJIB — express.json() akan menghancurkan raw body-nya.
app.post('/webhook/payqris', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'] || '';
const body = req.body.toString('utf8');
const expected = crypto
.createHmac('sha256', process.env.PAYQRIS_WEBHOOK_SECRET)
.update(body)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ message: 'Invalid signature' });
}
const { event, data } = JSON.parse(body);
if (event !== 'payment.paid') return res.status(400).end();
// Idempoten: kunci pada data.invoice_id.
return res.json({ ok: true });
});▸Next.js (Route Handler)
// app/api/webhook/payqris/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export async function POST(request: Request) {
const body = await request.text(); // raw, jangan request.json()
const signature = request.headers.get('x-webhook-signature') ?? '';
const expected = createHmac('sha256', process.env.PAYQRIS_WEBHOOK_SECRET!)
.update(body)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return Response.json({ message: 'Invalid signature' }, { status: 401 });
}
const { event, data } = JSON.parse(body);
if (event !== 'payment.paid') return new Response(null, { status: 400 });
// Idempoten: kunci pada data.invoice_id.
return Response.json({ ok: true });
}Catatan penting tentang balasan API
Tiga hal yang sering diasumsikan berbeda oleh yang baru memakai. Lebih baik diketahui sekarang daripada saat sudah berjalan di produksi.
Tidak ada pembungkus pada balasan create
POST /v1/pay/create mengembalikan datanya langsung di akar — tanpa field success dan tanpa data. Bacainvoice_id dan total_amount dari akar balasan. Kegagalan ditandai kode status HTTP, bukan oleh field di dalam badan.
Gambar QR dibuat di sisi Anda
Yang dikirim adalah qris_payload berupa teks, bukan berkas gambar. Ubah menjadi kode QR memakai pustaka apa pun di aplikasi Anda — begitu QR-nya dibuat di sisi Anda, ukurannya bisa disesuaikan dengan layar dan tidak ada gambar yang perlu diunduh tiap kali halaman dibuka.
invoice_id berbentuk UUID
Contoh: 3f9a2c14-8b7d-4e51-9a03-2f6c1d8e5b40. Bentuknya divalidasi ketat pada endpoint status, jadi kolom penyimpanannya harus muat 36 karakter. Untuk penomoran Anda sendiri, pakai merchant_order_id — bebas formatnya, dan ikut dikirim balik di webhook.
Galat
| Kode | Arti | Yang perlu dilakukan |
|---|---|---|
| 401 | API key tidak ada atau tidak valid | Periksa header Authorization dan kunci yang dipakai |
| 400 | Field tidak lolos validasi | Baca pesan galatnya; field asing juga ditolak |
| 404 | Tagihan tidak ditemukan | invoice_id salah, atau milik merchant lain |
| 429 | Terlalu banyak permintaan | Beri jeda, jangan panggil dalam gelung rapat |