---
name: tulus-merchant-reporting-sdk
description: Integrate with the Tulus (SnapNPay) payment gateway from any application — generate payment orders and QR codes, pull transaction/payment reports, and receive + verify server-to-server callbacks. Use when building checkout, order generation, reconciliation, or reporting against tulus.my / reports.tulus.my.
license: Provided by Tulus for use by integrating merchants.
---

# Tulus Merchant and Reporting SDK and Order generation skill

This skill teaches an agent how to integrate a third-party application with the
**Tulus** payment gateway (also branded **SnapNPay**). It is self-contained: an
agent can load this file and generate working integration code without reading
the portal. The reference SDK is PHP (`Tulus.php`), but every operation is a
plain HTTPS request and works from any language.

Canonical source: <https://developers.tulus.my/> (download this file from
<https://developers.tulus.my/SKILL.md>).

## What you can do

| Capability | Direction | Endpoint base |
|---|---|---|
| Generate a payment order / checkout | App -> Tulus | `https://tulus.my` |
| Generate a payment QR code (signed or unsigned) | App -> Tulus | `https://reports.tulus.my` |
| Pull payment / FPX / transaction reports | App -> Tulus | `https://reports.tulus.my` |
| Receive payment result callbacks | Tulus -> App | your callback URL |

## Endpoints

Two hosts:

- **Payment / checkout:** `https://tulus.my`
  - Checkout (POST a payment order): `https://tulus.my/v2/checkout`
  - Hosted pay page (GET URL): `https://tulus.my/pay/{agency}/{paymentCategory}/{refNo}/{amount}`
- **Reporting / generators:** `https://reports.tulus.my`
  - Payment report: `GET /api/report/v1/payment`
  - Single FPX request / order lookup: `GET /api/report/v1/fpxrequest`
  - Transaction history by email: `GET /api/user/v1/transactions`
  - QR code generator (signed): `POST /api/qrcode/v1/generator`
  - DuitNow QR generator (signed): `POST /api/duitnowqr/v1/generator`
  - Health: `GET /api/version`, `GET /api/echo`

## Authentication

There are two supported methods. Pick one.

### 1. AuthToken (Bearer JWT) — preferred for reporting

A JWT obtained by logging into the merchant dashboard at
<https://reports.tulus.my/> and choosing **"Copy AuthToken to Clipboard"** from
the top-right menu. It supports reporting across multiple related agencies in a
single token.

Send it as an HTTP header:

```
Authorization: Bearer <AUTHTOKEN>
```

### 2. API key (HMAC signature) — required for order/QR generation, fixed to one agency

A 128-bit UUID API key issued per agency (format `DACA31D4-869A-4323-9455-E4F533EB08DC`).
You must also send the `agency` field. Requests are signed: you append a
`signature` parameter computed with HMAC-SHA256. The key does **not** travel on
the wire — only the signature does.

> A third, older `token`/username+password method exists but is **deprecated** —
> do not use it. The SDK throws `DeprecatedException` if you try.

### Signature algorithm (the one thing to get right)

Used for signed GET URLs, signed POST bodies (QR generators), and for verifying
inbound callbacks. The algorithm is identical in all three cases:

1. Take the request parameters (the query for GET, the form fields for POST).
2. For **outbound** requests, add `exp = <unix epoch seconds> + 60` (a 60-second expiry). Callbacks you *verify* do not get an `exp` added — verify exactly what was sent.
3. **Sort keys alphabetically** (`ksort`).
4. Build the message string:
   - Outbound GET: `<path>?<rfc3986-querystring>` (path included, e.g. `/api/report/v1/payment?...`).
   - Outbound POST / callback verification: `<rfc3986-querystring>` (no path).
5. Convert the UUID API key to a 16-byte binary: strip the dashes, then hex-decode (`pack("H*", ...)`). This matches Go's `uuid.MarshalBinary`.
6. `digest = HMAC_SHA256(message, key)`, hex-encoded.
7. The signature is the **first 16 hex characters** of that digest.
8. Append `&signature=<16 hex chars>` to the request (or compare, for callbacks, using a constant-time comparison).

PHP reference (`Tulus.php`):

```php
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;
}
```

## Generate a payment order (checkout)

Post a buyer to the hosted checkout. Minimum order fields (all strings, ASCII,
<= 255 bytes; UTF-8 is undefined behaviour):

| Field | Required | Notes |
|---|---|---|
| `agency` | yes | Your agency code (e.g. `snapnpay`). |
| `refNo` | yes | Your unique reference for this order. Must not contain `~`. |
| `amount` | yes | Ringgit, 2 decimals as a string, e.g. `"1.00"`. Minimum `1.00`. |
| `email` | yes | Payer email; the receipt is sent here. |
| `returnUrl` | yes | Where the result is delivered (see Callbacks). |
| `signature` | when using API key | HMAC of the fields. |

`extraData` (optional, your own reference such as a MyKad number — any
alphanumeric except `~`) is conventionally appended to `refNo` with a `~`
delimiter: `refNo = "ORDER123~881225-08-1234"`.

Two ways to send the order:

- **POST form** to `https://tulus.my/v2/checkout` with the fields above
  (`Content-Type: application/x-www-form-urlencoded`). This is the normal
  browser checkout.
- **GET URL** (no POST form needed): build
  `https://tulus.my/pay/{agency}/{paymentCategory}/{urlencode(refNo)}/{amount}`
  and redirect the buyer to it.

## Generate a QR code

- **Unsigned** (anyone can generate): `GetQRCodeUrl($qrcode)` returns
  `https://tulus.my/qr2/?q=<urlencoded pay url>`.
- **Signed / tamper-resistant** (requires API key): POST the signed fields to
  `/api/qrcode/v1/generator` (`GetSecureQRCodeUrl`) — returns a URL serving the
  QR PNG. DuitNow variant: `/api/duitnowqr/v1/generator` (`GetDuitNowQRCodeUrl`).

QR fields: `agency`, `paymentCategory`, `refNo`, `amount` (>= `1.00`),
optional `extras` (key/value map). `refNo` must not contain `~`.

## Pull reports

`GET /api/report/v1/payment` returns paginated payment rows. Common query
parameters:

| Param | Meaning |
|---|---|
| `output` | `json` |
| `items` | page size; `-1` for all |
| `page` | 0-based page number |
| `seller_order_no` | filter by a specific `fpx_seller_order_no` |
| `start` / `end` | ISO date (UTC+8) range on `created_date` |
| `is_approved` | `0` or `1` |
| `agency` | required when authenticating by API key |

Response shape: `{ "status": "OK", "data": [ ...rows... ], "stats": { "total_pages": N } }`.
Iterate `page` from 0 until a page returns fewer rows than `items`.

Other lookups:

- `GET /api/report/v1/fpxrequest?fpx_seller_order_no=<orderNo>` — single order detail (or `?refno=<refNo>`).
- `GET /api/user/v1/transactions?email=<email>` — a payer's transaction history.

Example (curl, AuthToken):

```sh
curl -H "Authorization: Bearer $AUTHTOKEN" \
  "https://reports.tulus.my/api/report/v1/payment?output=json&items=20&page=0&is_approved=1"
```

Example (PHP SDK):

```php
require 'Tulus.php';
$tulus = new Tulus\Tulus();
$tulus->SetAuthToken($authToken);                 // or $tulus->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) { /* ... */ }
```

## Receive and verify callbacks

When a transaction reaches a terminal or pending state, Tulus POSTs to your
registered `returnUrl` (`application/x-www-form-urlencoded`). Respond **HTTP
2xx**; otherwise the callback is retried (~30s monitor cycle).

Two flavours:

- **Indirect** — POSTed by the buyer's browser after payment (an
  auto-submitting form, always a POST — a GET here means a server bug).
  **Not signed** — display-only; never fulfil from it. Its `status` is only
  `success`/`failed`, its `amount` is gross (includes `addCharge`), and
  `extraData` is omitted when empty.
- **Direct** — POSTed server-to-server by Tulus. **Signed** — verify it.

Top-level fields: `status`, `orderNo`, `refNo`, `amount`, `addCharge`,
`fpxTxnId`, `fpxTxnTime`, `agency`, `extraData`, `fpx` (a JSON string with full
detail), `signature`.

`status` is one of:

| `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 |

> **Only fulfil on `success`.** A `pending` may later become `success` or `failed`.
> The full FPX/PayNet `fpx_debit_auth_code` table (50+ codes) is documented at
> <https://developers.tulus.my/sdk/callback> — `00` = approved, everything else
> maps to `pending` or `failed` as above.

Verify a signed (direct) callback the same way you sign — HMAC over the sorted,
URL-encoded fields excluding `signature`, first 16 hex chars, key = UUID hex
packed to 16 bytes:

```php
$tulus = new Tulus\Tulus();
$tulus->SetApiKey($agency, $apiKey);
if (!$tulus->VerifyCallback($_POST)) {     // false for tampered/missing signature
    http_response_code(403); exit;
}
// $_POST['status'] === 'success' -> fulfil the order keyed by $_POST['refNo']
```

Raw verification (any language): take all POST fields except `signature`, sort
keys, build `k=v&k=v` with URL-encoding (space -> `+`, matching Go's
`url.QueryEscape`), `HMAC_SHA256(apiKey16bytes, that)`, hex, first 16 chars,
constant-time compare to the received `signature`.

## Quickstart for an agent

1. Confirm which agency code and auth method the integrating merchant has
   (AuthToken for read/reporting; API key for order/QR generation + callback
   verification).
2. Smoke-test connectivity: `GET https://reports.tulus.my/api/version`.
3. Generate an order: POST `agency,refNo,amount,email,returnUrl` to
   `https://tulus.my/v2/checkout` (sign if using API key).
4. Implement the callback receiver at `returnUrl`: verify signature (direct
   callbacks), act only on `status=success`, respond `200`.
5. Reconcile with `GET /api/report/v1/payment` or look up a single order via
   `/api/report/v1/fpxrequest`.

## Gotchas

- `~` is a reserved delimiter — never put it in `refNo` or `extraData`.
- `amount` is a string with 2 decimals and must be `>= 1.00`.
- Signatures are only the **first 16 hex characters** of the HMAC digest, not the full 64.
- The API key is a UUID but must be hex-decoded to **16 raw bytes** before use as the HMAC key — do not HMAC against the ASCII UUID string.
- Outbound signatures include `exp` (now+60s) and the URL path in the signed message; callback verification does **not** add `exp` and does **not** include the path.
- Field names use FPX/PayNet conventions (`fpx_*`) even for non-FPX methods (card/MPGS, Touch 'n Go). Use `fpx.payment_method` to tell them apart.
