1. Getting Started
Integrating Kryz Pay takes under 15 minutes. Follow these three steps to get your production API credentials:
Create Merchant
Register with your email and business details on the registration portal.
Register AccountOperator Review
Anti-fraud verification via WhatsApp. Typical approval within 15–60 minutes.
WhatsApp OperatorGenerate API Key
Create a scoped API key in your merchant dashboard. It is shown once, so copy it straight away.
Keys Portal2. 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: 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.
| Scope | Permission |
|---|---|
payments:read | Read status, details, and history of payments created by your merchant. |
payments:write | Create new payment sessions and allocate unique collision-free sen amounts. |
keys:read / keys:write | Manage secondary API keys and configure your signed merchant webhook endpoint. |
qr:write | Upload 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.
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
| Field | Type | Required | Description |
|---|---|---|---|
amount_sen | Integer | Optional | Order base price in integer sen (e.g. RM 12.00 is 1200). If omitted, an open amount is allocated. |
order_ref | String | Optional | Your internal order number or invoice reference (e.g. TOPUP-1234). Echoed in all queries and webhooks. |
expected_payer | String | Optional | Customer name or phone identifier for matching logs. |
Idempotency-Key | Header | Recommended | Unique client string per order so network retries never allocate duplicate payments. |
Response (201 Created)
{
"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"
}
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/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/guzzlewithRetryMiddleware. All three respectRetry-Afteror 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.
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)
{
"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
| Status | Badge | Description |
|---|---|---|
pending | Pending | Payment created and awaiting customer transfer. Expires 10 minutes from creation. |
received | Received | A matching notification was detected and matched. The amount is credited to your merchant balance. |
expired | Expired | The 10-minute window closed without an incoming transfer for this exact amount. |
7. List Payments
Retrieve historical payments scoped to your authenticated merchant account.
curl -X GET 'https://pay.kryznet.com/api/v1/payments?status=received&limit=20' \ -H 'Authorization: Bearer kpt_live_xxxxxxxxxxxxxxxxxxxxxxxx'
{
"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 bypending,received, orexpired.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
| Header | Example | Description |
|---|---|---|
X-KPT-Event | payment.received | Event type (payment.received or payment.expired). |
X-KPT-Timestamp | 1791215685800 | Unix timestamp in milliseconds when the signature was generated. |
X-KPT-Signature | a6f9b2c3... | Hex-encoded HMAC-SHA256 of <timestamp>.<raw_body> using your secret. |
X-KPT-Delivery | 42 | Unique 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-Version | v1 | Gateway 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
{
"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.referenceplus 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 code | Diagnostic explanation |
|---|---|
matched | The notification matched an open pending payment exactly. Funds were credited to your merchant balance. |
no_matching_payment | The customer transferred an amount that had no pending payment session (wrong amount, or paid without using checkout). |
payment_expired | The payment session existed, but the 10-minute window elapsed before the transfer arrived. |
duplicate_event | The terminal retransmitted an event key that had already been matched. Ignored safely. |
ambiguous_amount | Two 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:
{
"error": {
"message": "invalid_api_key",
"status": 401
}
}
| HTTP status | Meaning | Recommended remedy |
|---|---|---|
| 400 | Validation error | Check the payload, e.g. amount_sen must be a positive integer. |
| 401 | Unauthorized | Missing or invalid API key in the Bearer token header. |
| 403 | Forbidden / inactive | The key lacks the required scope, or the account is pending manual WhatsApp verification. |
| 404 | Not found | The payment reference does not exist or belongs to another merchant account. |
| 409 | Offset pool exhausted | All 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. |
| 500 | Internal gateway error | Unexpected 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
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.