How Tulus Works

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.

The big picture

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.

Your App
your server
create order / QR
pull reports
Checkout
tulus.my
Reporting
reports.tulus.my
signed callback
(payment result)
Your returnUrl
your callback endpoint
CapabilityDirectionHost
Generate a payment order / checkoutApp → Tulustulus.my
Generate a payment QR codeApp → Tulusreports.tulus.my
Pull payment / FPX / transaction reportsApp → Tulusreports.tulus.my
Receive payment result callbacksTulus → Appyour returnUrl

The end-to-end payment flow

A single payment travels through five stages. Only fulfil the order on a verified success callback — never on the buyer's browser redirect.

  1. Create an order

    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.

  2. Buyer pays

    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.

  3. Browser returns (indirect)

    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.

  4. Server callback (direct)

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

  5. Reconcile

    Pull GET /api/report/v1/payment or look up a single order via /api/report/v1/fpxrequest to reconcile your ledger against Tulus.

Authentication — pick one

AuthToken (Bearer JWT) reporting

A JWT copied from the merchant dashboard (top-right menu → "Copy AuthToken to Clipboard"). Spans multiple related agencies. Preferred for read/reporting.

http header
Authorization: Bearer <AUTHTOKEN>

API key (HMAC) orders + callbacks

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.

Fixed to one agency. You must also send the agency field with every request.
Deprecated: the older token / username+password method must not be used — the SDK throws DeprecatedException.

The signature algorithm (the one thing to get right)

The same algorithm signs GET URLs, signs POST bodies (QR generators), and verifies inbound callbacks:

  1. Take the request parameters (query for GET, form fields for POST).
  2. Outbound only: add exp = now + 60 (60 s expiry). Callback verification does not add exp.
  3. ksort — sort keys alphabetically.
  4. Build the message string:
    • Outbound GET: <path>?<rfc3986 querystring> (path included).
    • Outbound POST / callback verify: <querystring> (no path).
  5. Convert the UUID key to 16 raw bytes: strip dashes, hex-decode (pack("H*", ...) = Go's uuid.MarshalBinary).
  6. HMAC_SHA256(message, key), hex-encoded.
  7. The signature is the first 16 hex characters of the digest.
  8. Append &signature=<16 hex> (or constant-time compare, for callbacks).

Reference implementations

PHP
// 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;
}
Python
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]}"
Go
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
}
JavaScript
// 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)}`;
}

Signature playground live

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.

A test value; use your own agency key locally.
Signed message:
signature (first 16 hex):

Code recipes

1 · Create an order (checkout)

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
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"
GET URL
# 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
PHP
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".

2 · Pull a payment report

curl
curl -H "Authorization: Bearer $AUTHTOKEN" \
  "https://reports.tulus.my/api/report/v1/payment?output=json&items=20&page=0&is_approved=1"
PHP
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 */ }
Python
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.

3 · Verify a signed (direct) callback

PHP
$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
Python
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
Callback verify differs from outbound signing: no exp is added, no path is prepended, and the querystring uses url.QueryEscape encoding (space → +).

Callback status codes

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.

statusMeaningAction
successApproved (fpx_debit_auth_code = 00)Safe to fulfil
pendingOutcome unknown (auth code empty / 09 / 76 / 99)Wait for a follow-up callback
failedDeclined or cancelledDo not fulfil
Only fulfil on 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.

Gotchas that bite everyone

Keep going

SDK Playground

Try live requests with your own agency.

Launch

SKILL.md

Hand the self-contained skill to your AI coding assistant.

Download

Callback reference

Every fpx_debit_auth_code and signature detail.

Read