API OpenQRIS
Payment link QRIS. Buat order, dapat QRIS dinamis + link bayar, terima webhook saat dibayar.
Base URL API: https://api.openqris.com (path tanpa prefix /api). Halaman bayar & QR publik tetap di openqris.com. Alias lama openqris.com/api/... tetap jalan (backward compatible).
Tanda tangan webhook kini anti-replay: HMAC atas timestamp + "." + rawBody (bukan body saja). Verifikasi lama akan menolak semua webhook. Lihat bagian Webhook dan Changelog.
Mulai cepat
Empat langkah integrasi SaaS.
# 1) Set webhook + ambil secret (sekali)
curl -X POST https://api.openqris.com/v1/webhook \
-H "Authorization: Bearer oqk_..." \
-d '{ "url": "https://app-mu.com/api/openqris/hook" }'
# -> { "webhookUrl": "...", "webhookSecret": "whsec_..." } (simpan secret di env)
# 2) Buat order saat checkout
curl -X POST https://api.openqris.com/qris/orders \
-H "Authorization: Bearer oqk_..." \
-H "Idempotency-Key: <orderId-internal-mu>" \
-d '{ "amount": 50000, "ref": "INV-001", "metadata": {"userId":"u1"} }'
# -> { code, amount, payUrl, qrImageUrl, payload, ... }
# 3) Arahkan pelanggan ke payUrl (atau render QR dari payload / qrImageUrl)
# 4) Saat LUNAS, OpenQRIS POST webhook payment.paid ke webhookUrl-mu.
# Verifikasi signature (lihat bagian Webhook), lalu tandai invoice lunas.Autentikasi
Semua endpoint merchant pakai header Bearer dengan API key (dari Dashboard).
Authorization: Bearer oqk_xxxxxxxxxxxxxxxxEndpoint publik (tanpa auth): halaman bayar /p/:code dan QR image /qr/:code.png (kode tidak bisa ditebak).
Konsep nominal unik
Tiap order dapat nominal final = baseAmount + uniqueCode (1-999). Contoh: minta 50000 jadi harus dibayar 50217. Pelanggan WAJIB bayar nominal persis itu supaya pembayaran cocok otomatis ke order yang benar. Uang masuk langsung ke rekening merchant (non-custodial). Sumber kebenaran status = webhook.
Buat order
Header opsional Idempotency-Key (kunci sama mengembalikan order yang sama, tanpa kedaluwarsa).
| Field | Tipe | Keterangan |
|---|---|---|
| amount | int (wajib) | BASE amount dalam rupiah |
| ref | string | referensimu, di-echo di response + webhook |
| webhookUrl | string | override webhook khusus order ini |
| redirectUrl | url http/https | pelanggan auto balik ke sini setelah bayar |
| metadata | object | JSON bebas, di-echo di webhook + GET order |
| expiresIn | int detik | default 1800 (30 menit) |
| provider | string | "static" (default) | "autogopay" |
| test | bool | true = order sandbox (bisa simulate-paid) |
| png | bool | false = jangan sertakan pngDataUrl |
curl -X POST https://api.openqris.com/qris/orders \
-H "Authorization: Bearer oqk_..." \
-H "Idempotency-Key: order-123" \
-d '{
"amount": 50000,
"ref": "INV-001",
"webhookUrl": "https://app-mu.com/hook",
"redirectUrl": "https://app-mu.com/billing/sukses",
"metadata": { "userId": "u1", "plan": "pro" }
}'Response kanonik
Shape stabil yang dipakai create + GET order. payload SELALU ada.
{
"id": "ord_...",
"code": "rc6u03gola",
"ref": "INV-001",
"baseAmount": 50000,
"uniqueCode": 217,
"amount": 50217, // YANG HARUS DIBAYAR (base + unik)
"payload": "00020101...6304ABCD", // EMVCo string, render QR sendiri
"payUrl": "https://openqris.com/p/rc6u03gola",
"qrImageUrl": "https://api.openqris.com/qr/rc6u03gola.png",
"status": "pending", // pending|awaiting_confirmation|paid|expired|canceled
"kind": "api", // api|support|checkout
"isTest": false,
"metadata": { "userId": "u1", "plan": "pro" },
"redirectUrl": "https://app-mu.com/billing/sukses",
"expiresAt": "2026-06-26T10:30:00Z",
"paidAt": null,
"createdAt": "2026-06-26T10:00:00Z",
"statusUrl": "https://api.openqris.com/qris/orders/ord_...",
"pngDataUrl": "data:image/png;base64,..." // kecuali png:false (hanya di create)
}Ambil / cek order
Bearer. Mengembalikan shape kanonik lengkap (payload, qrImageUrl, metadata, status, expiresAt, paidAt).
curl https://api.openqris.com/qris/orders/ord_... \
-H "Authorization: Bearer oqk_..."Batalkan order
Bearer. Order pending jadi canceled, slot nominal unik bebas, webhook payment.canceled terkirim.
curl -X POST https://api.openqris.com/qris/orders/ord_.../cancel \
-H "Authorization: Bearer oqk_..."
# -> { "ok": true, "id": "ord_...", "status": "canceled" }List & export CSV
# JSON (default)
curl "https://api.openqris.com/qris/orders?status=paid&limit=200" \
-H "Authorization: Bearer oqk_..."
# -> { "orders": [ { id, code, status, amount, ref, webhookStatus, paidAt, ... } ] }
# CSV
curl "https://api.openqris.com/qris/orders?format=csv&limit=5000" \
-H "Authorization: Bearer oqk_..." -o orders.csvQR image hosted
PUBLIC, cacheable, langsung image/png. Cocok dikirim ke WhatsApp/email (tinggal kirim URL).
https://api.openqris.com/qr/rc6u03gola.pngWebhook (events + signature)
Sumber kebenaran status (bukan redirect/polling). Set URL + ambil secret:
POST /v1/webhook { "url": "https://app-mu.com/hook" }
# -> { "webhookSecret": "whsec_..." } simpan di envEvents
payment.paid- order LUNAS (ada retry backoff)payment.expired- order kedaluwarsa (bebasin slot nominal di sisimu)payment.canceled- dibatalkan via APIwebhook.test- hasil endpoint test
Event tak dikenal harus di-ignore dengan aman (forward-compatible).
Headers & payload
POST <webhookUrl>
X-OpenQRIS-Event: payment.paid
X-OpenQRIS-Delivery: evt_xxxxxxxx // dedup key (delivery at-least-once)
X-OpenQRIS-Timestamp: 1782000000 // unix detik
X-OpenQRIS-Signature: sha256=<hmac>
{
"event": "payment.paid",
"eventId": "evt_xxxxxxxx",
"orderId": "ord_...",
"code": "rc6u03gola",
"ref": "INV-001",
"amount": 50217,
"baseAmount": 50000,
"uniqueCode": 217,
"paidAmount": 50217, // null jika belum paid
"status": "paid",
"kind": "api",
"metadata": { "userId": "u1" },
"paidAt": "2026-06-26T03:11:00Z",
"createdAt": "2026-06-26T02:41:00Z"
}Verifikasi signature (anti-replay)
expected = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody). Pakai rawBody mentah (jangan re-serialize). Tolak bila selisih timestamp > 5 menit. Dedup pakai eventId.
// Node.js (Next.js: ambil rawBody via await req.text(), JANGAN req.json())
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret) {
const ts = headers['x-openqris-timestamp'];
const sig = headers['x-openqris-signature'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(ts + '.' + rawBody).digest('hex');
return sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}<?php // PHP
function verifyWebhook(string $rawBody, array $h, string $secret): bool {
$ts = $h['X-OpenQRIS-Timestamp'] ?? '';
$sig = $h['X-OpenQRIS-Signature'] ?? '';
if (abs(time() - (int)$ts) > 300) return false;
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
return hash_equals($expected, $sig);
}# Python
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
ts = headers["X-OpenQRIS-Timestamp"]
sig = headers["X-OpenQRIS-Signature"]
if abs(time.time() - int(ts)) > 300: return False
mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={mac}", sig)Test endpoint
curl -X POST https://api.openqris.com/v1/webhook/test \
-H "Authorization: Bearer oqk_..."
# -> { "delivered": true, "httpStatus": 200, "url": "..." }Retry payment.paid jika gagal: 1m, 5m, 15m, 1j, 3j, 6j, 12j lalu mati.
Sandbox / test mode
Uji loop create -> paid -> webhook tanpa uang nyata.
# 1) order test
curl -X POST https://api.openqris.com/qris/orders \
-H "Authorization: Bearer oqk_..." \
-d '{ "amount": 1000, "test": true, "webhookUrl": "https://app-mu.com/hook" }'
# 2) simulasikan LUNAS (HANYA order test) -> kirim webhook payment.paid asli
curl -X POST https://api.openqris.com/qris/orders/ord_.../simulate-paid \
-H "Authorization: Bearer oqk_..."
# -> { "ok": true, "status": "paid" }Idempotency
Header Idempotency-Key yang sama mengembalikan order yang SAMA (HTTP 200, tidak bikin baru), tanpa kedaluwarsa. Pakai orderId internal SaaS-mu sebagai kunci agar double-klik / retry aman.
Format error
{ "error": "pesan untuk manusia", "code": "INVALID_AMOUNT", "httpStatus": 400 }| Code | HTTP |
|---|---|
| UNAUTHORIZED | 401 |
| NOT_FOUND | 404 |
| BAD_REQUEST | 400 |
| INVALID_AMOUNT | 400 |
| AMOUNT_TOO_SMALL | 400 |
| SLOT_FULL | 409 |
| EXPIRED | 410 |
| ALREADY_PAID | 409 |
| NOT_TEST_ORDER | 403 |
| PROVIDER_ERROR | 502 |
| RATE_LIMITED | 429 (+ Retry-After) |
| SERVER_ERROR | 500 |
Mode halaman bayar
- kind=api (dibuat lewat API): checkout transaksional, "Terima kasih atas pembayaran Anda" + auto-redirect ke redirectUrl.
- kind=support (halaman dukungan publik): donasi + dinding supporter.
No-code: openqris.com/<username> (pelanggan ketik nominal) atau .../tx?amount=50000 (nominal terkunci).
Changelog & migrasi
26 Jun 2026
WAJIB (breaking):
[ ] Update verify webhook ke skema HMAC(`${timestamp}.${rawBody}`) (lihat #webhook)
[ ] Pakai rawBody mentah saat verifikasi (bukan re-serialize)
[ ] Handle event payment.expired & payment.canceled; ignore event tak dikenal
[ ] Dedup webhook pakai eventId (delivery at-least-once)
[ ] Jalankan POST /v1/webhook/test -> pastikan balas 2xx
BARU (opsional, tidak breaking):
- Response kanonik stabil; payload SELALU ada (tak perlu png:true)
- Field baru: qrImageUrl, kind, isTest, metadata, redirectUrl, uniqueCode, createdAt
- metadata passthrough (echo di webhook + GET) -> hapus lookup-by-ref
- qrImageUrl PNG hosted untuk WA/email
- redirectUrl auto balik ke app
- Test mode {test:true} + simulate-paid + webhook/test
- GET order kini shape kanonik penuh
- Cancel order + payment.canceled
- Error terstandar {error, code, httpStatus} + Retry-After di 429