KRYZ TERMINAL
LIVE
REST API v1 TNG eWallet Auto Match Signed Webhooks OpenAPI 3.0

Developer API Documentation

OpenAPI Spec (JSON)

Programmatic collection, collision-preventing sen offsets, and automated notification matching for Touch 'n Go eWallet payments in Malaysia.

Read first — how payments are verified. Kryz Pay is independent automation software and is not affiliated with, licensed by, or connected to TNG Digital Sdn Bhd, PayNet, or Bank Negara Malaysia. A payment is marked received when a genuine TNG eWallet notification for the exact allocated ringgit & sen amount appears on the paired terminal. Every received payment carries verification: "notification_only": treat it as a strong signal that the money arrived, not as proof of who sent it. For high-value orders, add your own checks.
API Base URL https://pay.kryznet.com/api/v1

1. Getting Started

Integrating Kryz Pay takes under 15 minutes. Follow these three steps to get your production API credentials:

Step 1

Create Merchant

Register with your email and business details on the registration portal.

Register Account
Step 2

Operator Review

Anti-fraud verification via WhatsApp. Typical approval within 15–60 minutes.

WhatsApp Operator
Step 3

Generate API Key

Create a scoped API key in your merchant dashboard. It is shown once, so copy it straight away.

Keys Portal

2. Authentication

All REST API requests need your secret merchant API key in the Authorization header as a Bearer token. Send requests from your server only — never from a browser or mobile app.

Authorization Header Format
Authorization: Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx

Available API Key Scopes

Keys are permission-isolated. If a key calls an endpoint outside its scope, a 403 insufficient_scope is returned.

ScopePermission
payments:readRead status, details, and history of payments created by your merchant.
payments:writeCreate new payment sessions and allocate unique collision-free sen amounts.
keys:read / keys:writeManage secondary API keys and configure your signed merchant webhook endpoint.
qr:writeUpload or update the merchant static receiving QR code.

3. Create a Payment

Allocates a collision-free sen offset for the requested base ringgit price and creates a 10-minute customer checkout session.

POST /api/v1/payments
curl -X POST https://pay.kryznet.com/api/v1/payments \
  -H 'Authorization: Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-1234' \
  -d '{
    "amount_sen": 1200,
    "order_ref": "TOPUP-1234",
    "expected_payer": "Ahmad"
  }'
const res = await fetch('https://pay.kryznet.com/api/v1/payments', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-1234'
  },
  body: JSON.stringify({
    amount_sen: 1200, // Base price: RM 12.00
    order_ref: 'TOPUP-1234',
    expected_payer: 'Ahmad'
  })
});

const data = await res.json();
console.log('Customer Payment URL:', data.pay_url);
console.log('Direct QR Code Image:', data.qr_image);
console.log('Exact Amount Customer Must Pay:', data.payment.amount_myr); // e.g. RM 12.28
import requests

headers = {
    'Authorization': 'Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-1234'
}

payload = {
    'amount_sen': 1200, # Base price: RM 12.00
    'order_ref': 'TOPUP-1234',
    'expected_payer': 'Ahmad'
}

res = requests.post('https://pay.kryznet.com/api/v1/payments', json=payload, headers=headers)
data = res.json()
print('Customer Payment URL:', data['pay_url'])
print('Direct QR Code Image:', data.get('qr_image'))
print('Exact Amount Customer Must Pay:', data['payment']['amount_myr'])
<?php
$ch = curl_init('https://pay.kryznet.com/api/v1/payments');
$payload = json_encode([
    'amount_sen' => 1200, // Base price: RM 12.00
    'order_ref' => 'TOPUP-1234',
    'expected_payer' => 'Ahmad'
]);

curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx',
        'Content-Type: application/json',
        'Idempotency-Key: order-1234'
    ]
]);

$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
echo "Payment URL: " . $data['pay_url'] . "\n";
echo "QR Image: " . $data['qr_image'] . "\n";
echo "Amount to Pay: RM " . $data['payment']['amount_myr'];

Request Parameters

FieldTypeRequiredDescription
amount_senIntegerOptionalOrder base price in integer sen (e.g. RM 12.00 is 1200). If omitted, an open amount is allocated.
order_refStringOptionalYour internal order number or invoice reference (e.g. TOPUP-1234). Echoed in all queries and webhooks.
expected_payerStringOptionalCustomer name or phone identifier for matching logs.
Idempotency-KeyHeaderRecommendedUnique client string per order so network retries never allocate duplicate payments.

Response (201 Created)

JSON Response Body
{
  "payment": {
    "reference": "PG-6C5CB378",
    "amount_sen": 1228,
    "amount_myr": "12.28",
    "base_amount_sen": 1200,
    "base_amount_myr": "12.00",
    "offset_sen": 28,
    "offset_myr": "0.28",
    "currency": "MYR",
    "status": "pending",
    "verification": "notification_only",
    "order_ref": "TOPUP-1234",
    "expected_payer": "Ahmad",
    "api_version": "v1",
    "expires_at": "2026-10-10T08:10:00.000Z",
    "created_at": "2026-10-10T08:00:00.000Z",
    "qr_image": "https://pay.kryznet.com/qr/0debedcfda207cbafcb9e3673cd16751.jpg"
  },
  "idempotent_replay": false,
  "pay_url": "https://pay.kryznet.com/pay/PG-6C5CB378",
  "qr_image": "https://pay.kryznet.com/qr/0debedcfda207cbafcb9e3673cd16751.jpg"
}
Show customers payment.amount_myr, not the base price. The customer must transfer the exact amount including the sen offset (e.g. 12.28, not 12.00). The hosted pay_url page does this for you.

4. Sen Offsets & HTTP 409 Handling

How collision prevention works

When a payment for base price RM 12.00 (1200 sen) is created, the gateway assigns a temporary unique offset between 1 and 99 sen (e.g. 1228 sen = RM 12.28). Because no two pending payments share the same ringgit/sen value at the same time, an incoming notification can be matched to exactly one order.

Concurrency limit & 409 retries

Each base price has 99 offset slots (1 to 99 sen), so one base amount can have up to 99 simultaneous pending checkout sessions. If all 99 are in use at the same moment, the API responds with:

HTTP 409 Conflict Response
HTTP/1.1 409 Conflict
Content-Type: application/json
Retry-After: 20

{
  "error": {
    "message": "no unique temporary amount available near amount_sen; retry shortly",
    "status": 409
  }
}

The Retry-After header tells you how many seconds to wait before retrying. If your HTTP client supports it, honour this value automatically; otherwise use the guidance below.

Recommended handling for 409

  • Retry with backoff: Pending sessions expire in 10 minutes (or settle within seconds). A retry after 15–30 seconds usually succeeds.
  • Use a retry library: Node.js — p-retry; Python — tenacity; PHP — guzzlehttp/guzzle with RetryMiddleware. All three respect Retry-After or accept a custom backoff schedule.
  • Price variation: For very high-volume concurrent sales (e.g. flash sales), stagger base prices by 5 or 10 sen (RM 10.00, RM 10.05, RM 10.10) to multiply the available slots.

5. Customer Checkout URL (pay_url) & Direct QR Image (qr_image)

You can either redirect the customer to pay_url, or embed the QR code image directly into your own website, checkout modal, or WhatsApp / Telegram / Discord bot using qr_image without sending customers away to the hosted page.

⚡ Direct In-App & Bot Integration with qr_image

The API now returns qr_image (direct public image URL), qr_url (dedicated payment QR endpoint /pay/:reference/qr), and qr_data_url (inline Base64 Data URI). You can render it directly with <img src="data.qr_image">, send it via WhatsApp bot media message, or display it inside your own UI.

Built into the customer pay page (if using pay_url)

  • Pay on this phone or another: customers choose “Pay on THIS phone” (copy the exact amount, save the QR, scan it from the gallery) or “Scan with ANOTHER phone” (scan the QR shown on screen).
  • Clear amount breakdown: base price plus the unique sen offset, with a one-tap copy button, so customers don’t round the amount down.
  • 10-minute countdown: a progress bar that turns amber, then red, as expiry approaches.
  • Live status: the page checks automatically, catches up as soon as the customer returns from their TNG app, and switches to a confirmation receipt when the payment is matched.
  • Contextual support: a WhatsApp button pre-filled with the payment reference.

6. Read Payment Status

Query the status of an active or settled payment by its reference code (e.g. PG-6C5CB378) or numeric database ID.

GET /api/v1/payments/:reference
curl -X GET https://pay.kryznet.com/api/v1/payments/PG-6C5CB378 \
  -H 'Authorization: Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx'
const res = await fetch('https://pay.kryznet.com/api/v1/payments/PG-6C5CB378', {
  headers: { 'Authorization': 'Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx' }
});
const data = await res.json();
if (data.payment.status === 'received') {
  console.log('Payment settled! Order confirmed. Reference:', data.payment.reference);
}
import requests

res = requests.get(
    'https://pay.kryznet.com/api/v1/payments/PG-6C5CB378',
    headers={'Authorization': 'Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx'}
)
data = res.json()
if data['payment']['status'] == 'received':
    print('Payment confirmed! Reference:', data['payment']['reference'])
<?php
$ch = curl_init('https://pay.kryznet.com/api/v1/payments/PG-6C5CB378');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx'
    ]
]);

$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
if ($data['payment']['status'] === 'received') {
    echo "Payment confirmed! Order ref: " . $data['payment']['order_ref'];
}

Response (200 OK)

JSON Response Body
{
  "payment": {
    "id": "a98df241bc",
    "reference": "PG-6C5CB378",
    "amount_sen": 1228,
    "amount_myr": "12.28",
    "base_amount_sen": 1200,
    "base_amount_myr": "12.00",
    "offset_sen": 28,
    "offset_myr": "0.28",
    "currency": "MYR",
    "status": "received",
    "verification": "notification_only",
    "order_ref": "TOPUP-1234",
    "expected_payer": "Ahmad",
    "api_version": "v1",
    "created_at": "2026-10-10T08:00:00.000Z",
    "settled_at": "2026-10-10T08:02:14.000Z",
    "expires_at": "2026-10-10T08:10:00.000Z"
  }
}

Payment lifecycle statuses

StatusBadgeDescription
pendingPendingPayment created and awaiting customer transfer. Expires 10 minutes from creation.
receivedReceivedA matching notification was detected and matched. The amount is credited to your merchant balance.
expiredExpiredThe 10-minute window closed without an incoming transfer for this exact amount.

7. List Payments

Retrieve historical payments scoped to your authenticated merchant account.

GET /api/v1/payments
cURL Example
curl -X GET 'https://pay.kryznet.com/api/v1/payments?status=received&limit=20' \
  -H 'Authorization: Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx'
Response Example
{
  "payments": [
    {
      "id": "a98df241bc",
      "reference": "PG-6C5CB378",
      "amount_sen": 1228,
      "amount_myr": "12.28",
      "base_amount_sen": 1200,
      "base_amount_myr": "12.00",
      "offset_sen": 28,
      "offset_myr": "0.28",
      "status": "received",
      "order_ref": "TOPUP-1234",
      "created_at": "2026-10-10T08:00:00.000Z"
    }
  ]
}

Query parameters

  • status (optional): filter by pending, received, or expired.
  • limit (optional): maximum records returned (1 to 200, default 50).

8. Merchant Webhooks (Signed Callbacks)

When a payment is matched, or expires, Kryz Pay sends an HTTP POST to your configured webhook URL with an HMAC-SHA256 signature.

Request headers

HeaderExampleDescription
X-KPT-Eventpayment.receivedEvent type (payment.received or payment.expired).
X-KPT-Timestamp1791215685800Unix timestamp in milliseconds when the signature was generated.
X-KPT-Signaturea6f9b2c3...Hex-encoded HMAC-SHA256 of <timestamp>.<raw_body> using your secret.
X-KPT-Delivery42Unique sequential delivery attempt identifier. Useful for logging; do not use it as a deduplication key, since retries of the same event carry a fresh value.
X-KPT-Api-Versionv1Gateway webhook schema version.

Signature verification code

Always verify the signature before processing an order, to confirm the webhook came from Kryz Pay. The cURL (test) tab sends a signed sample delivery to your own endpoint so you can try it locally.

# Send a signed TEST delivery to your own endpoint (sample payload, not a real payment).
# Change localhost:3000 below to your own publicly reachable URL before running.
SECRET='your_webhook_secret_here'
TS=$(( $(date +%s) * 1000 ))
BODY='{"event":"payment.received","api_version":"v1","data":{"payment":{"reference":"PG-TEST0001","order_ref":"TOPUP-1234","amount_myr":"12.28","base_amount_myr":"12.00","status":"received"}}}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -X POST http://localhost:3000/webhook \
  -H 'Content-Type: application/json' \
  -H "X-KPT-Timestamp: $TS" \
  -H "X-KPT-Signature: $SIG" \
  -d "$BODY"
const express = require('express');
const crypto = require('crypto');
const app = express();

// IMPORTANT: Parse raw body as Buffer or UTF-8 string to preserve exact JSON bytes
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const secret = process.env.KPT_WEBHOOK_SECRET; // from Merchant Dashboard
  const timestamp = req.headers['x-kpt-timestamp'];
  const signature = req.headers['x-kpt-signature'];
  const delivery = req.headers['x-kpt-delivery']; // sequential attempt id, useful for logs
  const rawBody = req.body.toString('utf8');

  // 1. Defend against replay attacks: reject requests older than 5 minutes
  if (!timestamp || Math.abs(Date.now() - Number(timestamp)) > 300_000) {
    return res.status(400).send('Timestamp expired or invalid');
  }

  // 2. Compute expected HMAC-SHA256 signature over "<timestamp>.<rawBody>"
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // 3. Timing-safe comparison to prevent side-channel timing leaks
  let valid = false;
  try {
    valid = crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
  } catch (_) {
    valid = false;
  }

  if (!valid) {
    return res.status(401).send('Invalid signature');
  }

  // 4. Handle event. Deduplicate on payment reference + event type
  // (retries reuse the same event; X-KPT-Delivery is a fresh id per attempt).
  const payload = JSON.parse(rawBody);
  if (payload.event === 'payment.received') {
    const payment = payload.data.payment;
    console.log(`[delivery ${delivery}] Order ${payment.order_ref} settled! Base: RM ${payment.base_amount_myr}`);
    // Credit your customer or deliver digital goods here.
    // IMPORTANT: make this idempotent — the same reference can arrive again on retry.
  }

  // 5. Must return HTTP 2xx within 10 seconds
  res.status(200).json({ received: true });
});

app.listen(3000);
import hmac
import hashlib
import time
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret_here".encode('utf-8')

@app.route('/webhook', methods=['POST'])
def webhook_handler():
    timestamp = request.headers.get('X-KPT-Timestamp', '')
    signature = request.headers.get('X-KPT-Signature', '')
    delivery = request.headers.get('X-KPT-Delivery', '')  # sequential attempt id, useful for logs
    raw_body = request.get_data() # Raw bytes

    # 1. Defend against replay attacks: reject requests older than 5 minutes
    try:
        ts_ms = int(timestamp)
        if abs(time.time() * 1000 - ts_ms) > 300000:
            return jsonify({'error': 'Timestamp expired'}), 400
    except ValueError:
        return jsonify({'error': 'Invalid timestamp'}), 400

    # 2. Compute expected HMAC-SHA256 over "<timestamp>.<raw_body>"
    signed_payload = f"{timestamp}.".encode('utf-8') + raw_body
    expected = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()

    # 3. Timing-safe comparison
    if not hmac.compare_digest(signature, expected):
        return jsonify({'error': 'Invalid signature'}), 401

    # 4. Handle event. Deduplicate on payment reference + event type.
    payload = request.get_json()
    if payload.get('event') == 'payment.received':
        payment = payload['data']['payment']
        print(f"[delivery {delivery}] Settled order {payment.get('order_ref')} for RM {payment.get('base_amount_myr')}")
        # IMPORTANT: make this idempotent — the same reference can arrive again on retry.

    # 5. Return HTTP 2xx
    return jsonify({'received': True}), 200

if __name__ == '__main__':
    app.run(port=3000)
<?php
$secret = 'your_webhook_secret_here';
$timestamp = $_SERVER['HTTP_X_KPT_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_KPT_SIGNATURE'] ?? '';
$delivery  = $_SERVER['HTTP_X_KPT_DELIVERY'] ?? ''; // sequential attempt id, useful for logs
$rawBody = file_get_contents('php://input');

// 1. Defend against replay attacks (5 minute threshold)
$nowMs = round(microtime(true) * 1000);
if (abs($nowMs - (int)$timestamp) > 300000) {
    http_response_code(400);
    exit(json_encode(['error' => 'Timestamp expired']));
}

// 2. Compute expected HMAC-SHA256
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

// 3. Timing-safe comparison
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit(json_encode(['error' => 'Invalid signature']));
}

// 4. Handle event. Deduplicate on payment reference + event type.
$payload = json_decode($rawBody, true);
if (($payload['event'] ?? '') === 'payment.received') {
    $payment = $payload['data']['payment'] ?? [];
    error_log("[delivery $delivery] Settled order " . ($payment['order_ref'] ?? '?')
        . " for RM " . ($payment['base_amount_myr'] ?? '?'));
    // IMPORTANT: make this idempotent — the same reference can arrive again on retry.
}

// 5. Acknowledge with 200 OK
http_response_code(200);
echo json_encode(['received' => true]);

Sample webhook payload

payment.received Event Payload
{
  "event": "payment.received",
  "api_version": "v1",
  "created_at": "2026-10-10T08:02:14.120Z",
  "data": {
    "payment": {
      "id": "a98df241bc",
      "reference": "PG-6C5CB378",
      "amount_sen": 1228,
      "amount_myr": "12.28",
      "base_amount_sen": 1200,
      "base_amount_myr": "12.00",
      "offset_sen": 28,
      "offset_myr": "0.28",
      "currency": "MYR",
      "status": "received",
      "verification": "notification_only",
      "order_ref": "TOPUP-1234",
      "expected_payer": "Ahmad",
      "api_version": "v1",
      "created_at": "2026-10-10T08:00:00.000Z",
      "settled_at": "2026-10-10T08:02:14.000Z",
      "expires_at": "2026-10-10T08:10:00.000Z"
    }
  }
}

Delivery & retry policy

  • Response expectation: your server must return any HTTP 2xx status within 10 seconds.
  • Automatic retries: if your server times out or returns non-2xx, Kryz Pay retries up to 6 times with exponential backoff (immediate, 5s, 10s, 20s, 40s, 80s, up to 5 minutes).
  • Handle repeats safely: because deliveries are retried, the same event can arrive more than once. Deduplicate on data.payment.reference plus the event type, and return 2xx for event types you don’t handle.
  • Manual re-dispatch: view failed deliveries and retry them anytime in the Merchant Dashboard or via POST /api/v1/webhook-deliveries/:id/retry.

9. Idempotency

No duplicate payments for the same key

Always send an Idempotency-Key header when calling POST /api/v1/payments. If the network drops and you retry, sending the same key returns the original payment with "idempotent_replay": true (HTTP 200) instead of allocating another sen offset or creating a second pending session.

10. Matching Engine & Diagnostics

Every notification forwarded from the terminal is logged and evaluated. To audit unmatched transfers or troubleshoot a customer payment, query GET /api/v1/events or open the Merchant Ledger:

Outcome codeDiagnostic explanation
matchedThe notification matched an open pending payment exactly. Funds were credited to your merchant balance.
no_matching_paymentThe customer transferred an amount that had no pending payment session (wrong amount, or paid without using checkout).
payment_expiredThe payment session existed, but the 10-minute window elapsed before the transfer arrived.
duplicate_eventThe terminal retransmitted an event key that had already been matched. Ignored safely.
ambiguous_amountTwo active payments shared the same sen value. The atomic offset allocator is designed to prevent this.

11. Error Codes Reference

All errors return JSON with an error message and the HTTP status code:

Standard Error Format
{
  "error": {
    "message": "invalid_api_key",
    "status": 401
  }
}
HTTP statusMeaningRecommended remedy
400Validation errorCheck the payload, e.g. amount_sen must be a positive integer.
401UnauthorizedMissing or invalid API key in the Bearer token header.
403Forbidden / inactiveThe key lacks the required scope, or the account is pending manual WhatsApp verification.
404Not foundThe payment reference does not exist or belongs to another merchant account.
409Offset pool exhaustedAll 99 sen slots for this base amount are in use. Wait 15–30s or vary the base amount. Honour the Retry-After header if present.
500Internal gateway errorUnexpected server error. It is logged automatically for operator review.

12. Terminal Webhook (Internal Forwarder)

Reserved for paired Android notification forwarders on dedicated hardware. Every payload is authenticated with an HMAC-SHA256 signature over <timestamp>.<raw body>. Merchants don’t call this endpoint.

POST /v1/webhooks/tng
Terminal Ingest Packet
POST /v1/webhooks/tng
X-TNG-Timestamp: 1791215685800
X-TNG-Signature: d2f84b6c8f93...hex_hmac_sha256...

{
  "event_key": "notif_8f93a104",
  "amount_sen": 1228,
  "direction": "in",
  "notified_at": 1791215685800
}

13. OpenAPI 3.0 & Tooling

Import into Postman or Swagger UI

Kryz Pay publishes an OpenAPI 3.0.3 specification covering the merchant endpoints, request schemas, and response formats.

14. Go-live Checklist

Tick these off before sending live traffic. Your progress is saved in this browser only.

Need developer support or account activation?

Direct operator help for key provisioning, custom rate limits, and webhook debugging.