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).

Perubahan terbaru (breaking) - 26 Jun 2026

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_xxxxxxxxxxxxxxxx

Endpoint 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

POST /qris/orders

Header opsional Idempotency-Key (kunci sama mengembalikan order yang sama, tanpa kedaluwarsa).

FieldTipeKeterangan
amountint (wajib)BASE amount dalam rupiah
refstringreferensimu, di-echo di response + webhook
webhookUrlstringoverride webhook khusus order ini
redirectUrlurl http/httpspelanggan auto balik ke sini setelah bayar
metadataobjectJSON bebas, di-echo di webhook + GET order
expiresInint detikdefault 1800 (30 menit)
providerstring"static" (default) | "autogopay"
testbooltrue = order sandbox (bisa simulate-paid)
pngboolfalse = 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

GET /qris/orders/:id

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

POST /qris/orders/:id/cancel

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

GET /qris/orders?status=&limit=&format=
# 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.csv

QR image hosted

GET /qr/:code.png

PUBLIC, cacheable, langsung image/png. Cocok dikirim ke WhatsApp/email (tinggal kirim URL).

https://api.openqris.com/qr/rc6u03gola.png

Webhook (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 env

Events

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 }
CodeHTTP
UNAUTHORIZED401
NOT_FOUND404
BAD_REQUEST400
INVALID_AMOUNT400
AMOUNT_TOO_SMALL400
SLOT_FULL409
EXPIRED410
ALREADY_PAID409
NOT_TEST_ORDER403
PROVIDER_ERROR502
RATE_LIMITED429 (+ Retry-After)
SERVER_ERROR500

Mode halaman bayar

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