PayNow QR Code API
Create PayNow QR codes from your own server. Send the payment details as JSON. You get back the payload (the text inside the QR code), the values in it, and the QR image as PNG or SVG.
Overview
There is one endpoint. Requests and responses are JSON over HTTPS.
The API version is in the URL. We may add new fields to v1 responses. Your code should ignore fields it does not know.
POST https://kachingqr.com/api/v1/qr
Authentication
Create an API key on your dashboard. Send it in the Authorization header of every request.
We show the key only once, when you create it. You can have up to 5 keys. All your keys share one rate limit.
The code samples read the key from the API_KEY environment variable. This keeps the key out of your source code.
Choose your language below. Every sample on this page then uses it.
Use your API key only from server-side code.
Never put it in browser JavaScript or a mobile app. Anyone can read it there. The API does not accept calls from browsers on other websites (no CORS). Call it from your backend. If someone else gets your key, revoke it on the dashboard and create a new one.
Authorization: Bearer YOUR_API_KEY
export API_KEY='your-api-key'
curl — Preinstalled on macOS and most Linux distributions. The examples read the JSON response with jq.
Requires curl. The payload, image and error examples also need jq.
Guzzle — The most used HTTP client for PHP. Laravel's HTTP client is built on it.
Requires PHP 8.1+ and Guzzle 7.
composer require guzzlehttp/guzzle
requests — The most used HTTP library for Python. It is simpler than the built-in urllib.
Requires Python 3.8+ and requests.
pip install requests
fetch — Built into Node.js, so no package is needed.
Requires Node.js 18+. Save the file as an ES module, for example qr.mjs. The code uses top-level await, which only works in an ES module.
net/http — Part of the standard library, so you do not need to install a module.
Requires Go 1.18+. Standard library only.
HttpClient and Jackson — HttpClient is part of the JDK. The JDK has no JSON parser, so the examples use Jackson. It is the most used JSON library for Java.
Requires Java 11+ and Jackson 2. Save the file as Main.java.
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.3</version>
</dependency>
implementation 'com.fasterxml.jackson.core:jackson-databind:2.22.3'
Generate a QR code
Send POST /api/v1/qr with a JSON body.
Parameters
-
proxy_typestring Required - "uen" for a business UEN, or "mobile" for a Singapore mobile number.
-
proxy_valuestring Required - The UEN or the mobile number. A UEN is the ID number of a Singapore business or organisation, for example 201912345K. A mobile number looks like 91234567 or +6591234567.
-
amountstring or number - The amount in Singapore dollars, for example "10.50". It must be more than zero, with at most 2 decimal places. Required when amount_editable is false. Send it as a string, because numbers with decimals can lose precision.
-
amount_editableboolean - If true, the payer can change the amount in their banking app. If you send no amount, the payer types one.
Default:
false. -
expirystring - The last day the payer can pay with this QR code. Use YYYY-MM-DD, in Singapore time. It must be today or later.
-
referencestring - A reference the payer sees, for example an invoice number. 1 to 25 letters, digits, spaces or hyphens.
-
merchant_namestring - The name of the person or business that gets the money. Some banking apps show it. 1 to 25 printable ASCII characters.
Default:
"NA". -
image_formatstring - "png", "svg" or "none". With "none" you get the payload without an image.
Default:
"png".
Returns
The response has the values exactly as they are in the payload. For example, a mobile number always starts with +65. Show these values to the payer: their banking app shows the same.
-
data.payloadstring - The payload in EMVCo format, the standard for payment QR codes. Draw it as a QR code yourself, or use image.
-
data.proxy_typestring - "uen" or "mobile".
-
data.proxy_valuestring - As in the payload. A mobile number is always +65 and 8 digits.
-
data.amountstring or null - Always with 2 decimal places, for example "10.50". null if you sent no amount.
-
data.amount_editableboolean - true if the payer can change the amount.
-
data.expirystring or null - The last day to pay, as YYYY-MM-DD in Singapore time. null if you sent none.
-
data.referencestring or null - As in the payload. null if you sent none.
-
data.merchant_namestring - "NA" if you sent none.
-
data.imageobject or null - Has format, mime_type and base64. base64 is the whole image file, not a data URI. null when image_format is "none".
Request — S$10.50 to a UEN, with a fixed amount, a reference and a last day to pay
curl · Requires curl. The payload, image and error examples also need jq.
curl -sS --max-time 30 -X POST https://kachingqr.com/api/v1/qr \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"proxy_type":"uen","proxy_value":"201912345K","amount":"10.50","amount_editable":false,"expiry":"2026-11-03","reference":"INV-1001","merchant_name":"Acme Pte Ltd"}'
Guzzle · Requires PHP 8.1+ and Guzzle 7.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client();
$response = $client->post('https://kachingqr.com/api/v1/qr', [
'headers' => [
'Authorization' => 'Bearer '.getenv('API_KEY'),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'proxy_type' => 'uen',
'proxy_value' => '201912345K',
'amount' => '10.50',
'amount_editable' => false,
'expiry' => '2026-11-03',
'reference' => 'INV-1001',
'merchant_name' => 'Acme Pte Ltd',
],
'timeout' => 30,
]);
echo $response->getBody(), PHP_EOL;
requests · Requires Python 3.8+ and requests.
import os
import requests
response = requests.post(
"https://kachingqr.com/api/v1/qr",
headers={
"Authorization": "Bearer " + os.environ["API_KEY"],
"Content-Type": "application/json",
"Accept": "application/json",
},
json={
"proxy_type": "uen",
"proxy_value": "201912345K",
"amount": "10.50",
"amount_editable": False,
"expiry": "2026-11-03",
"reference": "INV-1001",
"merchant_name": "Acme Pte Ltd",
},
timeout=30,
)
response.raise_for_status()
print(response.text)
fetch · Requires Node.js 18+. Save the file as an ES module, for example qr.mjs. The code uses top-level await, which only works in an ES module.
const response = await fetch("https://kachingqr.com/api/v1/qr", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
proxy_type: "uen",
proxy_value: "201912345K",
amount: "10.50",
amount_editable: false,
expiry: "2026-11-03",
reference: "INV-1001",
merchant_name: "Acme Pte Ltd",
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
console.log(await response.text());
net/http · Requires Go 1.18+. Standard library only.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
body, err := json.Marshal(map[string]any{
"proxy_type": "uen",
"proxy_value": "201912345K",
"amount": "10.50",
"amount_editable": false,
"expiry": "2026-11-03",
"reference": "INV-1001",
"merchant_name": "Acme Pte Ltd",
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, "https://kachingqr.com/api/v1/qr", bytes.NewReader(body))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
log.Fatalf("HTTP %d: %s", resp.StatusCode, respBody)
}
fmt.Println(string(respBody))
}
HttpClient and Jackson · Requires Java 11+ and Jackson 2. Save the file as Main.java.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
public class Main {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> body = Map.of(
"proxy_type", "uen",
"proxy_value", "201912345K",
"amount", "10.50",
"amount_editable", false,
"expiry", "2026-11-03",
"reference", "INV-1001",
"merchant_name", "Acme Pte Ltd");
HttpRequest request = HttpRequest.newBuilder(URI.create("https://kachingqr.com/api/v1/qr"))
.header("Authorization", "Bearer " + System.getenv("API_KEY"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("HTTP " + response.statusCode() + ": " + response.body());
}
System.out.println(response.body());
}
}
Response
{
"data": {
"payload": "00020101021126490009SG.PAYNOW010120210201912345K03010040820261103520400005303702540510.505802SG5912Acme Pte Ltd6009Singapore62120108INV-1001630446B2",
"proxy_type": "uen",
"proxy_value": "201912345K",
"amount": "10.50",
"amount_editable": false,
"expiry": "2026-11-03",
"reference": "INV-1001",
"merchant_name": "Acme Pte Ltd",
"image": {
"format": "png",
"mime_type": "image/png",
"base64": "iVBORw0KGgo…"
}
}
}
Images
image.base64 is the whole image file. Decode it to save the file.
In the PNG, each small square of the QR code (a module) is 10 pixels. It has the standard white border (the quiet zone).
The SVG scales to any size, for example for print.
To show the image in a web page, use a data URI: data:{mime_type};base64,{base64}.
If you draw the QR code yourself, send "image_format": "none". The response is then much smaller.
curl · Requires curl. The payload, image and error examples also need jq.
curl -sS --max-time 30 -X POST https://kachingqr.com/api/v1/qr \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"proxy_type":"uen","proxy_value":"201912345K","amount":"25","image_format":"png"}' \
| jq -r '.data.image.base64' | base64 --decode > paynow-qr.png
Guzzle · Requires PHP 8.1+ and Guzzle 7.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client();
$response = $client->post('https://kachingqr.com/api/v1/qr', [
'headers' => [
'Authorization' => 'Bearer '.getenv('API_KEY'),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'proxy_type' => 'uen',
'proxy_value' => '201912345K',
'amount' => '25',
'image_format' => 'png',
],
'timeout' => 30,
]);
$image = json_decode((string) $response->getBody(), true)['data']['image'];
file_put_contents('paynow-qr.'.$image['format'], base64_decode($image['base64']));
requests · Requires Python 3.8+ and requests.
import base64
import os
import requests
response = requests.post(
"https://kachingqr.com/api/v1/qr",
headers={
"Authorization": "Bearer " + os.environ["API_KEY"],
"Content-Type": "application/json",
"Accept": "application/json",
},
json={
"proxy_type": "uen",
"proxy_value": "201912345K",
"amount": "25",
"image_format": "png",
},
timeout=30,
)
response.raise_for_status()
image = response.json()["data"]["image"]
with open("paynow-qr." + image["format"], "wb") as file:
file.write(base64.b64decode(image["base64"]))
fetch · Requires Node.js 18+. Save the file as an ES module, for example qr.mjs. The code uses top-level await, which only works in an ES module.
import { writeFile } from "node:fs/promises";
const response = await fetch("https://kachingqr.com/api/v1/qr", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
proxy_type: "uen",
proxy_value: "201912345K",
amount: "25",
image_format: "png",
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const { image } = (await response.json()).data;
await writeFile(`paynow-qr.${image.format}`, Buffer.from(image.base64, "base64"));
net/http · Requires Go 1.18+. Standard library only.
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
body, err := json.Marshal(map[string]any{
"proxy_type": "uen",
"proxy_value": "201912345K",
"amount": "25",
"image_format": "png",
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, "https://kachingqr.com/api/v1/qr", bytes.NewReader(body))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
log.Fatalf("HTTP %d: %s", resp.StatusCode, respBody)
}
var qr struct {
Data struct {
Image struct {
Format string `json:"format"`
Base64 string `json:"base64"`
} `json:"image"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &qr); err != nil {
log.Fatal(err)
}
image, err := base64.StdEncoding.DecodeString(qr.Data.Image.Base64)
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("paynow-qr."+qr.Data.Image.Format, image, 0o644); err != nil {
log.Fatal(err)
}
}
HttpClient and Jackson · Requires Java 11+ and Jackson 2. Save the file as Main.java.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Base64;
import java.util.Map;
public class Main {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> body = Map.of(
"proxy_type", "uen",
"proxy_value", "201912345K",
"amount", "25",
"image_format", "png");
HttpRequest request = HttpRequest.newBuilder(URI.create("https://kachingqr.com/api/v1/qr"))
.header("Authorization", "Bearer " + System.getenv("API_KEY"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("HTTP " + response.statusCode() + ": " + response.body());
}
JsonNode image = mapper.readTree(response.body()).at("/data/image");
Files.write(
Path.of("paynow-qr." + image.get("format").asText()),
Base64.getDecoder().decode(image.get("base64").asText()));
}
}
Payload only — a mobile number. The payer types the amount.
curl · Requires curl. The payload, image and error examples also need jq.
curl -sS --max-time 30 -X POST https://kachingqr.com/api/v1/qr \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"proxy_type":"mobile","proxy_value":"91234567","amount_editable":true,"image_format":"none"}' \
| jq -r '.data.payload'
Guzzle · Requires PHP 8.1+ and Guzzle 7.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client();
$response = $client->post('https://kachingqr.com/api/v1/qr', [
'headers' => [
'Authorization' => 'Bearer '.getenv('API_KEY'),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'proxy_type' => 'mobile',
'proxy_value' => '91234567',
'amount_editable' => true,
'image_format' => 'none',
],
'timeout' => 30,
]);
$qr = json_decode((string) $response->getBody(), true);
echo $qr['data']['payload'], PHP_EOL;
requests · Requires Python 3.8+ and requests.
import os
import requests
response = requests.post(
"https://kachingqr.com/api/v1/qr",
headers={
"Authorization": "Bearer " + os.environ["API_KEY"],
"Content-Type": "application/json",
"Accept": "application/json",
},
json={
"proxy_type": "mobile",
"proxy_value": "91234567",
"amount_editable": True,
"image_format": "none",
},
timeout=30,
)
response.raise_for_status()
print(response.json()["data"]["payload"])
fetch · Requires Node.js 18+. Save the file as an ES module, for example qr.mjs. The code uses top-level await, which only works in an ES module.
const response = await fetch("https://kachingqr.com/api/v1/qr", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
proxy_type: "mobile",
proxy_value: "91234567",
amount_editable: true,
image_format: "none",
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const qr = await response.json();
console.log(qr.data.payload);
net/http · Requires Go 1.18+. Standard library only.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
body, err := json.Marshal(map[string]any{
"proxy_type": "mobile",
"proxy_value": "91234567",
"amount_editable": true,
"image_format": "none",
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, "https://kachingqr.com/api/v1/qr", bytes.NewReader(body))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
log.Fatalf("HTTP %d: %s", resp.StatusCode, respBody)
}
var qr struct {
Data struct {
Payload string `json:"payload"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &qr); err != nil {
log.Fatal(err)
}
fmt.Println(qr.Data.Payload)
}
HttpClient and Jackson · Requires Java 11+ and Jackson 2. Save the file as Main.java.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
public class Main {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> body = Map.of(
"proxy_type", "mobile",
"proxy_value", "91234567",
"amount_editable", true,
"image_format", "none");
HttpRequest request = HttpRequest.newBuilder(URI.create("https://kachingqr.com/api/v1/qr"))
.header("Authorization", "Bearer " + System.getenv("API_KEY"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("HTTP " + response.statusCode() + ": " + response.body());
}
JsonNode qr = mapper.readTree(response.body());
System.out.println(qr.at("/data/payload").asText());
}
}
Errors
Every error has the same JSON shape. code is for your program to check. message is for people to read.
field is the request field with the problem, or null. A validation_failed error also lists every problem in errors.
Check code, not message. Codes never change in v1. We may improve the messages.
-
unauthenticatedHTTP 401 - The Authorization header is missing, or the API key is wrong or revoked.
-
invalid_jsonHTTP 400 - The request body is not valid JSON. For example, a quote or bracket is missing, or there is an extra comma.
-
validation_failedHTTP 422 - A field is missing, has the wrong type, or has a value we do not accept. "errors" lists every problem.
-
invalid_uenHTTP 422 - proxy_value is not a valid UEN.
-
invalid_mobileHTTP 422 - proxy_value is not a Singapore mobile number. It must be 8 digits that start with 8 or 9. +65 in front is allowed.
-
invalid_amountHTTP 422 - amount is zero or less, or it has too many digits. It can have at most 10 digits before the decimal point and 2 after it.
-
amount_requiredHTTP 422 - amount is missing, but amount_editable is false.
-
invalid_expiryHTTP 422 - expiry is before today (Singapore date).
-
invalid_referenceHTTP 422 - reference is not 1 to 25 letters, digits, spaces or hyphens.
-
invalid_merchant_nameHTTP 422 - merchant_name is not 1 to 25 printable ASCII characters.
-
unsupported_proxyHTTP 422 - This payment scheme does not support this proxy type.
-
invalid_payment_dataHTTP 422 - We could not build a valid payload from these values.
-
not_foundHTTP 404 - The endpoint does not exist. Check the URL, including the /api/v1 prefix.
-
method_not_allowedHTTP 405 - The endpoint exists, but it does not accept this HTTP method. Use POST.
-
rate_limitedHTTP 429 - Too many requests. Wait for the number of seconds in the Retry-After header.
-
request_rejectedHTTP 4xx - We rejected the request before it reached the endpoint. The HTTP status is the original 4xx status, for example 413 when the body is too large.
-
server_errorHTTP 500 - Something went wrong on our side. Try again later.
curl · Requires curl. The payload, image and error examples also need jq.
response=$(curl -sS --max-time 30 -X POST https://kachingqr.com/api/v1/qr \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"proxy_type":"uen","proxy_value":"201912345K","amount":"10.50","amount_editable":false,"expiry":"2026-11-03","reference":"INV-1001","merchant_name":"Acme Pte Ltd"}' \
-w '\n%{http_code}')
http_code=$(tail -n 1 <<< "$response")
body=$(sed '$d' <<< "$response")
if [ "$http_code" = 200 ]; then
jq -r '.data.payload' <<< "$body"
else
case $(jq -r '.error.code' <<< "$body") in
rate_limited) jq -r '.error.message' <<< "$body" ;;
validation_failed) jq -r '.error.errors[] | "\(.field): \(.message)"' <<< "$body" ;;
*) jq -r '.error | "\(.code): \(.message)"' <<< "$body" ;;
esac
fi
Guzzle · Requires PHP 8.1+ and Guzzle 7.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client();
$response = $client->post('https://kachingqr.com/api/v1/qr', [
'headers' => [
'Authorization' => 'Bearer '.getenv('API_KEY'),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'proxy_type' => 'uen',
'proxy_value' => '201912345K',
'amount' => '10.50',
'amount_editable' => false,
'expiry' => '2026-11-03',
'reference' => 'INV-1001',
'merchant_name' => 'Acme Pte Ltd',
],
'timeout' => 30,
'http_errors' => false,
]);
$body = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() === 200) {
echo $body['data']['payload'], PHP_EOL;
} else {
$error = $body['error'];
switch ($error['code']) {
case 'rate_limited':
echo 'Rate limited. Retry in '.$response->getHeaderLine('Retry-After').' seconds.', PHP_EOL;
break;
case 'validation_failed':
foreach ($error['errors'] as $problem) {
echo $problem['field'].': '.$problem['message'], PHP_EOL;
}
break;
default:
echo $error['code'].': '.$error['message'], PHP_EOL;
}
}
requests · Requires Python 3.8+ and requests.
import os
import requests
response = requests.post(
"https://kachingqr.com/api/v1/qr",
headers={
"Authorization": "Bearer " + os.environ["API_KEY"],
"Content-Type": "application/json",
"Accept": "application/json",
},
json={
"proxy_type": "uen",
"proxy_value": "201912345K",
"amount": "10.50",
"amount_editable": False,
"expiry": "2026-11-03",
"reference": "INV-1001",
"merchant_name": "Acme Pte Ltd",
},
timeout=30,
)
body = response.json()
if response.status_code == 200:
print(body["data"]["payload"])
else:
error = body["error"]
if error["code"] == "rate_limited":
print("Rate limited. Retry in " + response.headers["Retry-After"] + " seconds.")
elif error["code"] == "validation_failed":
for problem in error["errors"]:
print(problem["field"] + ": " + problem["message"])
else:
print(error["code"] + ": " + error["message"])
fetch · Requires Node.js 18+. Save the file as an ES module, for example qr.mjs. The code uses top-level await, which only works in an ES module.
const response = await fetch("https://kachingqr.com/api/v1/qr", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
proxy_type: "uen",
proxy_value: "201912345K",
amount: "10.50",
amount_editable: false,
expiry: "2026-11-03",
reference: "INV-1001",
merchant_name: "Acme Pte Ltd",
}),
signal: AbortSignal.timeout(30_000),
});
const body = await response.json();
if (response.ok) {
console.log(body.data.payload);
} else {
const { error } = body;
switch (error.code) {
case "rate_limited":
console.log(`Rate limited. Retry in ${response.headers.get("Retry-After")} seconds.`);
break;
case "validation_failed":
for (const problem of error.errors) {
console.log(`${problem.field}: ${problem.message}`);
}
break;
default:
console.log(`${error.code}: ${error.message}`);
}
}
net/http · Requires Go 1.18+. Standard library only.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
body, err := json.Marshal(map[string]any{
"proxy_type": "uen",
"proxy_value": "201912345K",
"amount": "10.50",
"amount_editable": false,
"expiry": "2026-11-03",
"reference": "INV-1001",
"merchant_name": "Acme Pte Ltd",
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, "https://kachingqr.com/api/v1/qr", bytes.NewReader(body))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
var result struct {
Data struct {
Payload string `json:"payload"`
} `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Errors []struct {
Field string `json:"field"`
Message string `json:"message"`
} `json:"errors"`
} `json:"error"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
log.Fatal(err)
}
if resp.StatusCode == http.StatusOK {
fmt.Println(result.Data.Payload)
return
}
switch result.Error.Code {
case "rate_limited":
fmt.Printf("Rate limited. Retry in %s seconds.\n", resp.Header.Get("Retry-After"))
case "validation_failed":
for _, problem := range result.Error.Errors {
fmt.Printf("%s: %s\n", problem.Field, problem.Message)
}
default:
fmt.Printf("%s: %s\n", result.Error.Code, result.Error.Message)
}
}
HttpClient and Jackson · Requires Java 11+ and Jackson 2. Save the file as Main.java.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
public class Main {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> body = Map.of(
"proxy_type", "uen",
"proxy_value", "201912345K",
"amount", "10.50",
"amount_editable", false,
"expiry", "2026-11-03",
"reference", "INV-1001",
"merchant_name", "Acme Pte Ltd");
HttpRequest request = HttpRequest.newBuilder(URI.create("https://kachingqr.com/api/v1/qr"))
.header("Authorization", "Bearer " + System.getenv("API_KEY"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode result = mapper.readTree(response.body());
if (response.statusCode() == 200) {
System.out.println(result.at("/data/payload").asText());
return;
}
JsonNode error = result.get("error");
switch (error.get("code").asText()) {
case "rate_limited":
String retryAfter = response.headers().firstValue("Retry-After").orElse("?");
System.out.println("Rate limited. Retry in " + retryAfter + " seconds.");
break;
case "validation_failed":
for (JsonNode problem : error.get("errors")) {
System.out.println(problem.get("field").asText() + ": " + problem.get("message").asText());
}
break;
default:
System.out.println(error.get("code").asText() + ": " + error.get("message").asText());
}
}
}
{
"error": {
"code": "invalid_uen",
"message": "This UEN is not valid. A UEN is the ID number of a Singapore business or organisation, for example 201912345K, 53312345A or T08LL1234A.",
"field": "proxy_value"
}
}
{
"error": {
"code": "validation_failed",
"message": "proxy_value is required. Add it to the JSON body.",
"field": "proxy_value",
"errors": [
{
"field": "proxy_value",
"message": "proxy_value is required. Add it to the JSON body."
},
{
"field": "expiry",
"message": "expiry must be a real date in the YYYY-MM-DD format."
}
]
}
}
{
"error": {
"code": "unauthenticated",
"message": "The API key is missing, wrong or revoked. Send a valid key in the header \"Authorization: Bearer <your-api-key>\".",
"field": null
}
}
Rate limits
Each account can make 30 requests per minute and 500 requests per day. All its API keys share these limits. Every request with a valid key counts, even one that gets a 422. So fix invalid input instead of sending it again.
Each limit period (a window) starts with its first request. It lasts one minute or 24 hours. We count requests sent at the same time exactly, so they cannot get past the limit. A request blocked by the minute limit does not count toward the daily limit.
When you reach a limit, the API answers 429 Too Many Requests. Wait Retry-After seconds, then try again.
-
X-RateLimit-Limit - Requests allowed per minute (30).
-
X-RateLimit-Remaining - Requests left in the current minute window.
-
X-RateLimit-Reset - When the minute window starts again, as a Unix timestamp in seconds.
-
X-RateLimit-Limit-Day - Requests allowed per day (500).
-
X-RateLimit-Remaining-Day - Requests left in the current day window.
-
X-RateLimit-Reset-Day - When the day window starts again, as a Unix timestamp in seconds.
-
Retry-After - Only on a 429 response: the number of seconds to wait before the next request.
Retry-After: 42
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
{
"error": {
"code": "rate_limited",
"message": "You reached the limit of 30 requests per minute. All your API keys share this limit. Try again in 42 seconds.",
"field": null
}
}
Usage
Your dashboard shows how many requests each key made and how many QR codes it created. It shows today and the last 30 days. Days are in Singapore time.
We store only these daily counts. We never store or log the payment details you send (UEN, mobile number, amount, reference). We never store or log the payloads we create either.