A developer's-eye tour of the Tulus (SnapNPay) payment gateway: the moving parts, the end-to-end payment flow, authentication & signing, and copy-and-run code for every step.
Everything you build talks to two hosts. Your app calls out to create orders and pull reports; Tulus calls back to your returnUrl when a payment settles.
returnUrl| Capability | Direction | Host |
|---|---|---|
| Generate a payment order / checkout | App → Tulus | tulus.my |
| Generate a payment QR code | App → Tulus | reports.tulus.my |
| Pull payment / FPX / transaction reports | App → Tulus | reports.tulus.my |
| Receive payment result callbacks | Tulus → App | your returnUrl |
A single payment travels through five stages. Only fulfil the order on a verified success callback — never on the buyer's browser redirect.
Your app POSTs the order fields to https://tulus.my/v2/checkout (or redirects to a hosted pay URL). If you authenticate by API key, you sign the fields.
Tulus hosts the checkout: the buyer picks a bank / DuitNow / card and completes payment on the gateway. You don't handle card or FPX data.
The buyer's browser is POSTed back to your returnUrl as an auto-submitting form. This is unsigned — display only. Show a "thank you" page; do not fulfil from it.
Tulus POSTs server-to-server to the same returnUrl with a signature. Verify it, and act only when status=success. Respond HTTP 2xx or it is retried (~30 s loop).
Pull GET /api/report/v1/payment or look up a single order via /api/report/v1/fpxrequest to reconcile your ledger against Tulus.
A JWT copied from the merchant dashboard (top-right menu → "Copy AuthToken to Clipboard"). Spans multiple related agencies. Preferred for read/reporting.
Authorization: Bearer <AUTHTOKEN>
A per-agency UUID (e.g. DACA31D4-869A-4323-9455-E4F533EB08DC). Required to generate signed orders/QRs and to verify inbound callbacks. The key never travels on the wire — only the derived signature does.
agency field with every request.token / username+password method must not be used — the SDK throws DeprecatedException.The same algorithm signs GET URLs, signs POST bodies (QR generators), and verifies inbound callbacks:
exp = now + 60 (60 s expiry). Callback verification does not add exp.ksort — sort keys alphabetically.<path>?<rfc3986 querystring> (path included).<querystring> (no path).pack("H*", ...) = Go's uuid.MarshalBinary).HMAC_SHA256(message, key), hex-encoded.&signature=<16 hex> (or constant-time compare, for callbacks).// Tulus.php — sign an outbound GET URL
public function SignURL($url, $hash) {
$u = parse_url($url);
parse_str($u['query'], $q);
$q['exp'] = time() + 60;
ksort($q);
$message = $u['path'] . '?' . http_build_query($q, "", '&', PHP_QUERY_RFC3986);
$key = pack("H*", str_replace("-", "", $hash)); // UUID -> 16 raw bytes
$hashed = substr(hash_hmac("sha256", $message, $key), 0, 16);
return $u['scheme'] . "://" . $u['host'] . $message . "&signature=" . $hashed;
}
import hmac, hashlib, time
from urllib.parse import urlencode, quote
def sign_get(path, params, api_key):
params = dict(params, exp=int(time.time()) + 60)
qs = urlencode(sorted(params.items()), quote_via=quote) # RFC3986
message = f"{path}?{qs}"
key = bytes.fromhex(api_key.replace("-", "")) # UUID -> 16 bytes
digest = hmac.new(key, message.encode(), hashlib.sha256).hexdigest()
return f"{message}&signature={digest[:16]}"
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"github.com/google/uuid"
)
// keyBytes = the 16 raw bytes of the agency API key UUID.
func sign(message string, apiKey uuid.UUID) string {
keyBytes, _ := apiKey.MarshalBinary() // UUID -> 16 bytes
mac := hmac.New(sha256.New, keyBytes)
mac.Write([]byte(message))
return hex.EncodeToString(mac.Sum(nil))[:16] // first 16 hex chars
}
// Node 18+ / browser (Web Crypto). Signs an outbound GET.
async function signGet(path, params, apiKey) {
params = { ...params, exp: Math.floor(Date.now() / 1000) + 60 };
const qs = Object.keys(params).sort()
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&');
const message = `${path}?${qs}`;
const raw = Uint8Array.from(apiKey.replace(/-/g, '').match(/../g).map(h => parseInt(h, 16)));
const key = await crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
const hex = [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, '0')).join('');
return `${message}&signature=${hex.slice(0, 16)}`;
}
Compute a real signature in your browser (HMAC-SHA256 via Web Crypto — nothing is sent anywhere). Enter a test API-key UUID and the request parameters, and watch the signed URL build exactly as SignURL would.
Minimum fields (all strings, ASCII, ≤ 255 bytes): agency, refNo (no ~), amount (2 decimals, ≥ 1.00), email, returnUrl, plus signature when using an API key.
curl -X POST https://tulus.my/v2/checkout \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "agency=snapnpay" \
--data-urlencode "refNo=ORDER123" \
--data-urlencode "amount=1.00" \
--data-urlencode "[email protected]" \
--data-urlencode "returnUrl=https://your.app/callback"
# Redirect the buyer to a hosted pay page (no POST form):
https://tulus.my/pay/{agency}/{paymentCategory}/{urlencode(refNo)}/{amount}
# Example:
https://tulus.my/pay/snapnpay/general/ORDER123/1.00
require 'Tulus.php';
$tulus = new Tulus\Tulus();
$tulus->SetApiKey('snapnpay', $apiKey); // signs the order
echo $tulus->GetCheckoutUrl([
'refNo' => 'ORDER123',
'amount' => '1.00',
'email' => '[email protected]',
'returnUrl' => 'https://your.app/callback',
]);
extraData (your own reference, e.g. a MyKad number) is conventionally appended to refNo with a ~ delimiter: refNo = "ORDER123~881225-08-1234".curl -H "Authorization: Bearer $AUTHTOKEN" \
"https://reports.tulus.my/api/report/v1/payment?output=json&items=20&page=0&is_approved=1"
require 'Tulus.php';
$tulus = new Tulus\Tulus();
$tulus->SetAuthToken($authToken); // or ->SetApiKey($agency, $apiKey)
$report = json_decode($tulus->GetPaymentReport('json', [
'start' => '2026-06-01', 'end' => '2026-06-30',
'is_approved' => '1', 'page' => 0,
]), true);
foreach ($report['data'] as $row) { /* reconcile */ }
import requests
r = requests.get("https://reports.tulus.my/api/report/v1/payment",
headers={"Authorization": f"Bearer {authtoken}"},
params={"output": "json", "items": 20, "page": 0, "is_approved": 1})
data = r.json()
# Iterate `page` from 0 until a page returns fewer than `items` rows.
for row in data["data"]:
...
Response: { "status": "OK", "data": [ ...rows... ], "stats": { "total_pages": N } }. Page from 0 until a page returns fewer than items rows.
$tulus = new Tulus\Tulus();
$tulus->SetApiKey($agency, $apiKey);
if (!$tulus->VerifyCallback($_POST)) { // false = tampered / missing signature
http_response_code(403); exit;
}
if ($_POST['status'] === 'success') {
// fulfil the order keyed by $_POST['refNo']
}
http_response_code(200); // else Tulus retries
import hmac, hashlib
from urllib.parse import urlencode
def verify_callback(form: dict, api_key: str) -> bool:
got = form.get("signature", "")
fields = {k: v for k, v in form.items() if k != "signature"}
qs = urlencode(sorted(fields.items())) # space -> '+', no path, no exp
key = bytes.fromhex(api_key.replace("-", ""))
want = hmac.new(key, qs.encode(), hashlib.sha256).hexdigest()[:16]
return hmac.compare_digest(got, want) # constant-time
exp is added, no path is prepended, and the querystring uses url.QueryEscape encoding (space → +).Tulus delivers the result twice on the same returnUrl: an indirect (browser, unsigned, display-only) POST and a direct (server-to-server, signed) POST. Act only on the verified direct one.
status | Meaning | Action |
|---|---|---|
| success | Approved (fpx_debit_auth_code = 00) | Safe to fulfil |
| pending | Outcome unknown (auth code empty / 09 / 76 / 99) | Wait for a follow-up callback |
| failed | Declined or cancelled | Do not fulfil |
success. A pending may later become success or failed. The full FPX/PayNet fpx_debit_auth_code table (50+ codes) is at /sdk/callback.Top-level fields: status, orderNo, refNo, amount, addCharge, fpxTxnId, fpxTxnTime, agency, extraData, fpx (a JSON string with full detail), signature.
~ is a reserved delimiter — never put it in refNo or extraData.amount is a string with 2 decimals and must be ≥ 1.00.exp (now+60s) and the URL path; callback verification includes neither.fpx_*) even for card/MPGS and Touch 'n Go — use fpx.payment_method to tell them apart.