Konek QR

Invoices through the API, paid notices by signed webhook.

A REST API v1 described in OpenAPI 3.1, Standard Webhooks to several endpoints, events you can read back for 30 days, and a test mode to try it all without real money.

One request, one invoice.

Send an amount; Konek QR adds the unique code and answers with the QR, a payment page link and the exact total to pay. Send the buyer to payUrl, or render qrString with any QR library.

REST API v1 endpoints
POST/v1/paymentsCreate an invoice. Idempotency-Key required.payments:write
GET/v1/payments/{id}Invoice status, plus qrString while pending.payments:read
GET/v1/paymentsNewest first; filter by status and referenceId.payments:read
POST/v1/payments/{id}/cancelCancel a pending invoice.payments:write
POST/v1/payments/{id}/simulateMark as paid, test mode only.payments:write
GET/v1/qrisUsable QRIS, with their NMID.payments:read
GET/v1/eventsEvents after a cursor, long-polling up to 30 s.events:read
GET/v1/devicesListener phones and whether they are online.payments:read
Create a test invoice
curl https://api.konekqr.web.id/v1/payments \
  -H "Authorization: Bearer $KONEKQR_KEY" \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{"amount": 25000, "referenceId": "INV-1001", "expiresInMinutes": 15}'
Sample 201 response
{
  "payment": {
    "id": "01972d1c-4b8e-7c3a-9d2f-1a2b3c4d5e6f",
    "livemode": false,
    "status": "pending",
    "amount": 25193,
    "baseAmount": 25000,
    "uniqueCode": 193,
    "referenceId": "INV-1001",
    "expiresAt": "2026-11-04T09:45:00Z",
    "payUrl": "https://pay.konekqr.web.id/p/01972d1c-…",
    "qrString": "00020101021226…6304ABCD",
    "qris": {
      "provider": "shopeepay",
      "merchantName": "TOKO CONTOH",
      "nmid": "ID0000000000000"
    }
  }
}

Signed webhooks, to several servers.

  • Standard Webhooks format: webhook-id, webhook-timestamp and webhook-signature (HMAC-SHA256).
  • Up to 5 endpoints per account, each with its own secret and event filter. Rotating a secret keeps the old one valid for 24 hours.
  • HTTPS only. Redirects are not followed, private addresses are refused.
  • Answer 2xx within 15 seconds. Failures are retried for about 3 days: 1 minute, 5 minutes, 30 minutes, 2 hours and so on.
  • Events: payment.paid, payment.expired, payment.cancelled, payment.unmatched, device.offline, device.online, topup.paid.

Webhook Doctor

  1. 403Add a WAF skip rule for the KonekQR-Webhooks user agentcloudflare_challenge
  2. 419Exclude the webhook route from Laravel’s VerifyCsrfTokencsrf_rejected
  3. 3xxUse the final URL; redirects are not followedredirect
  4. TLSFix your server’s HTTPS certificatetls_error
  5. 15 sAnswer first, process afterwardstimeout

Diagnoses appear in the dashboard delivery log, next to every failed attempt.

A sample webhook delivery
POST /webhooks/konekqr HTTP/1.1
content-type: application/json
user-agent: KonekQR-Webhooks/1
webhook-id: evt_01972d1f-…
webhook-timestamp: 1793784672
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id":"evt_01972d1f-…","type":"payment.paid","livemode":false,
 "createdAt":"2026-11-04T09:31:12Z","data":{ …the invoice… }}
Verify in Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyKonekQRWebhook(secret, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
  const id = headers['webhook-id'] ?? '';
  const timestamp = headers['webhook-timestamp'] ?? '';
  if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) throw new Error('stale');
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
  for (const signature of (headers['webhook-signature'] ?? '').split(' ')) {
    const [version, value = ''] = signature.split(',', 2);
    const given = Buffer.from(value, 'base64');
    if (version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected)) {
      return JSON.parse(rawBody.toString());
    }
  }
  throw new Error('bad signature');
}

PHP, Python, Go and cURL examples

No public URL? You still get the news.

GET /v1/events returns the events after a cursor, oldest first. With wait=30 the request waits until something new happens, so you just call it in a loop. Events are kept for 30 days: a consumer that was down picks up from its last cursor.

Long-polling events
curl "https://api.konekqr.web.id/v1/events?after=evt_01972d1f-…&types=payment.paid&wait=30" \
  -H "Authorization: Bearer $KONEKQR_KEY"

# → { "events": [ … ], "next": "evt_01972d2a-…" }

Try it all without real money.

  • Test keys

    A kq_test_… key only sees and creates test-mode objects. Live and test keys are created separately in the dashboard, and shown only once.

  • QRs that cannot be paid

    Test invoice QRs use the issuer ID.KONEKQR.TEST and the name KONEK QR TEST, so payer apps refuse them. Their payment page carries a TEST MODE banner.

  • Simulated payments

    POST /v1/payments/{id}/simulate marks a test invoice paid and sends payment.paid exactly like a real payment.

  • Never uses the quota

    Test transactions count towards neither quota nor fees, and have their own pending limit.

SDKs for Node.js and Laravel.

@konekqr/node for Node.js 20.3+ and konekqr/laravel for Laravel 12/13: create and read invoices, iterate events and verify webhooks. Install with npm install @konekqr/node or composer require konekqr/laravel.

Node.js: @konekqr/node
import { KonekQR } from '@konekqr/node';

const kq = new KonekQR({ apiKey: process.env.KONEKQR_KEY });
const payment = await kq.createPayment(
  { amount: 25000, referenceId: 'INV-1001' },
  { idempotencyKey: 'order-1001' },
);
for await (const event of kq.events({ types: ['payment.paid'] })) {
  console.log(event.data.referenceId, 'paid');
}
Laravel: konekqr/laravel
use KonekQR\Laravel\Facades\KonekQR;

$payment = KonekQR::createPayment(
    ['amount' => 25000, 'referenceId' => 'INV-1001'],
    idempotencyKey: 'order-1001',
);

Safeguards you can switch on.

So your bot never ships goods for a payment that cannot be confirmed, or to the wrong QRIS.

  • requireDetection

    The invoice is refused (409 detection_offline) when no Listener phone was active in the last 7 minutes.

  • expectedNmid

    The invoice is refused (409 nmid_mismatch) when the QRIS is not the NMID you expect. The API and webhooks always include the NMID and merchant name.

  • Idempotency-Key

    Resend the same request after a timeout and you get the same invoice, not a second one. Valid for 24 hours.

  • Keys are server-only

    Requests carrying a browser Origin are refused (403 browser_not_allowed). Scopes per key: payments:read, payments:write, events:read. Limit: 120 requests a minute per key.

  • Errors a machine can read

    Every error is RFC 9457 application/problem+json with a stable code. Send Accept-Language: en for English messages. Every error code.

Connect your QRIS and take your first payment today.

Sign up free, upload your static QRIS, then try it in test mode before you take real money.