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.

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

Header
Authorization: Bearer YOUR_API_KEY
Shell
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.

Generate a QR code

Send POST /api/v1/qr with a JSON body.

Parameters

proxy_type string Required
"uen" for a business UEN, or "mobile" for a Singapore mobile number.
proxy_value string 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.
amount string 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_editable boolean
If true, the payer can change the amount in their banking app. If you send no amount, the payer types one. Default: false.
expiry string
The last day the payer can pay with this QR code. Use YYYY-MM-DD, in Singapore time. It must be today or later.
reference string
A reference the payer sees, for example an invoice number. 1 to 25 letters, digits, spaces or hyphens.
merchant_name string
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_format string
"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.payload string
The payload in EMVCo format, the standard for payment QR codes. Draw it as a QR code yourself, or use image.
data.proxy_type string
"uen" or "mobile".
data.proxy_value string
As in the payload. A mobile number is always +65 and 8 digits.
data.amount string or null
Always with 2 decimal places, for example "10.50". null if you sent no amount.
data.amount_editable boolean
true if the payer can change the amount.
data.expiry string or null
The last day to pay, as YYYY-MM-DD in Singapore time. null if you sent none.
data.reference string or null
As in the payload. null if you sent none.
data.merchant_name string
"NA" if you sent none.
data.image object 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"}'

Response

200 OK
{
    "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

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'

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.

unauthenticated HTTP 401
The Authorization header is missing, or the API key is wrong or revoked.
invalid_json HTTP 400
The request body is not valid JSON. For example, a quote or bracket is missing, or there is an extra comma.
validation_failed HTTP 422
A field is missing, has the wrong type, or has a value we do not accept. "errors" lists every problem.
invalid_uen HTTP 422
proxy_value is not a valid UEN.
invalid_mobile HTTP 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_amount HTTP 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_required HTTP 422
amount is missing, but amount_editable is false.
invalid_expiry HTTP 422
expiry is before today (Singapore date).
invalid_reference HTTP 422
reference is not 1 to 25 letters, digits, spaces or hyphens.
invalid_merchant_name HTTP 422
merchant_name is not 1 to 25 printable ASCII characters.
unsupported_proxy HTTP 422
This payment scheme does not support this proxy type.
invalid_payment_data HTTP 422
We could not build a valid payload from these values.
not_found HTTP 404
The endpoint does not exist. Check the URL, including the /api/v1 prefix.
method_not_allowed HTTP 405
The endpoint exists, but it does not accept this HTTP method. Use POST.
rate_limited HTTP 429
Too many requests. Wait for the number of seconds in the Retry-After header.
request_rejected HTTP 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_error HTTP 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
422 Unprocessable Content
{
    "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"
    }
}
422 Unprocessable Content
{
    "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."
            }
        ]
    }
}
401 Unauthorized
{
    "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.
429 Too Many Requests — headers
Retry-After: 42
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
429 Too Many Requests — body
{
    "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.