PQ

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:

  1. 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.
  2. 2Minimal satu merchant di akun tersebut. Kalau akunnya belum punya merchant, penyambungan ditolak dengan pesanAkun GoBiz tidak memiliki merchant.
  3. 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

  1. 1Hubungkan akun GoBiz. Sekali saja, lewat dashboard. PayQris memakai QRIS statis merchant Anda sebagai dasar.
  2. 2Buat tagihan. Aplikasi Anda memanggil POST /v1/pay/create. PayQris menambahkan kode unik pada nominal, lalu mengembalikan QRIS dinamis.
  3. 3Pelanggan membayar. Yang dibayar adalah total_amount, bukan amount.
  4. 4PayQris memberi tahu. Begitu pelunasan tercocokkan, webhook payment.paid dikirim 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

IstilahArtinya di PayQris
MerchantAkun usaha Anda di PayQris. SATU saja, walau aplikasinya banyak.
API keyKredensial per aplikasi. Satu aplikasi satu kunci, dibuat di tab Aplikasi.
Webhook URLAlamat di server ANDA. PayQris mengirim notifikasi ke sana.
Webhook secretKunci untuk memverifikasi bahwa notifikasi itu memang dari PayQris.
invoice_idID tagihan dari PayQris, berbentuk UUID.
merchant_order_idNomor 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/json

Base URL: https://api.payqris.com

Membuat tagihan

POST /v1/pay/create
FieldTipeWajibKeterangan
amountintegeryaNominal dasar, 100 – 10.000.000
merchant_order_idstringtidakNomor pesanan Anda, maks 80 karakter
customer_namestringtidakMaks 120 karakter
expires_in_minutesintegertidakMasa 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}/status

Dipakai 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

KodeArtiYang perlu dilakukan
401API key tidak ada atau tidak validPeriksa header Authorization dan kunci yang dipakai
400Field tidak lolos validasiBaca pesan galatnya; field asing juga ditolak
404Tagihan tidak ditemukaninvoice_id salah, atau milik merchant lain
429Terlalu banyak permintaanBeri jeda, jangan panggil dalam gelung rapat