# Laravel integration handoff: payment page + callback receiver

This page is written for a coding agent (Claude Code, Cursor, Copilot, or a
human following along) that has been asked to integrate a **Laravel** application
with the Tulus payment gateway (also branded SnapNPay). Follow it top to bottom.
Every fact here is derived from the gateway's own source and is kept in sync
with it; where this page and older examples disagree, this page wins.

Companion references (read on demand, not required):

- Canonical agent skill: <https://developers.tulus.my/SKILL.md>
- Callback field reference and auth-code table: <https://developers.tulus.my/sdk/callback>
- Visual walkthrough with a live signature calculator: <https://developers.tulus.my/how-it-works/>

Target stack: Laravel 11 or 12, PHP 8.2+. Everything is plain HTTPS forms and
HMAC, so no Composer package is needed. Do not install a third-party "SnapNPay"
package; none is maintained.

---

## 0. What you are building

Two things on the merchant side:

1. **A payment page**: the shopper lands on your order page, you POST a signed
   form to `https://tulus.my/v2/checkout`, Tulus hosts the bank/card/DuitNow
   selection and the actual payment.
2. **One return URL** (`returnUrl`) that receives the result **twice**, both as
   `POST application/x-www-form-urlencoded`:
   - **Direct** callback: server-to-server from Tulus, carries a `signature`.
     Authoritative. Retried about every 30 seconds until you answer 2xx.
   - **Indirect** return: the shopper's browser is sent back to the same URL by
     an auto-submitting hidden form. No `signature`. Display-only.

The single rule that keeps money safe: **fulfil an order only on a signed
direct callback whose `status` is `success`** (or after confirming through the
Report API). Never fulfil from the indirect return; it comes from the shopper's
machine.

## 1. Inputs to collect before coding

| Item | Where it comes from | Example |
|---|---|---|
| Agency code | Tulus onboarding | `snapnpay` (case-insensitive on input; callbacks return it uppercased) |
| API key (UUID) | Merchant dashboard at reports.tulus.my, API keys page | `DACA31D4-869A-4323-9455-E4F533EB08DC` |
| Public HTTPS return URL | Your app | `https://shop.example.com/tulus/return` |

The API key never travels on the wire. Only a 16-hex-character HMAC signature
derived from it does. Store it in `.env`, never in code or the database.

Amount rules the gateway enforces server-side (mirror them in validation):

- `amount` is a **string with two decimals** in Malaysian Ringgit: `"1.00"`.
- Minimum net amount `1.00` (FPX B2B corporate accounts: `2.00`).
- `refNo` is your unique order reference. It must not contain `~`.
- Optional `extraData` is appended to `refNo` with a `~` delimiter:
  `refNo = "ORDER123~881225-08-1234"`. It comes back to you unchanged.

## 2. Configuration

`.env`:

```dotenv
TULUS_AGENCY=snapnpay
TULUS_API_KEY=DACA31D4-869A-4323-9455-E4F533EB08DC
TULUS_CHECKOUT_URL=https://tulus.my/v2/checkout
TULUS_REPORT_URL=https://reports.tulus.my
TULUS_RETURN_URL=https://shop.example.com/tulus/return
```

`config/tulus.php`:

```php
<?php

return [
    'agency'       => env('TULUS_AGENCY'),
    'api_key'      => env('TULUS_API_KEY'),
    'checkout_url' => env('TULUS_CHECKOUT_URL', 'https://tulus.my/v2/checkout'),
    'report_url'   => env('TULUS_REPORT_URL', 'https://reports.tulus.my'),
    'return_url'   => env('TULUS_RETURN_URL'),
];
```

## 3. The signer (get this right first, test it with the vectors below)

One class does all signing and verification. The algorithm is the same
HMAC-SHA256 everywhere; only the message differs.

| Use | Message | `exp` added? |
|---|---|---|
| Sign the checkout POST | `/v2/checkout?` + sorted, URL-encoded fields | yes, `exp = now + 60` is added **as a field** and signed |
| Sign a Report API GET | `<path>?` + sorted, URL-encoded query | yes |
| Verify a direct callback | sorted, URL-encoded POST fields **without** `signature`, no path | no, verify exactly what arrived |

Encoding must match Go's `url.QueryEscape`: PHP's `http_build_query` default
(RFC 1738, space becomes `+`) does, with one difference: PHP encodes `~` as
`%7E` while Go leaves it. Your `refNo` must never contain `~` inside a signed
value, so this never bites in practice; the `~` used to append `extraData` is
handled below by encoding with RFC 3986 for the checkout message.

`app/Services/Tulus/TulusSigner.php`:

```php
<?php

namespace App\Services\Tulus;

final class TulusSigner
{
    private string $key; // 16 raw bytes

    public function __construct(string $apiKeyUuid)
    {
        // UUID -> 16 raw bytes, matching Go's uuid.MarshalBinary. Never HMAC the ASCII UUID.
        $this->key = pack('H*', str_replace('-', '', $apiKeyUuid));
    }

    /** Sign the fields you will POST to /v2/checkout. Returns the fields plus exp + signature. */
    public function signCheckout(array $fields, string $path = '/v2/checkout'): array
    {
        $fields['exp'] = (string) (time() + 60);
        ksort($fields);
        // RFC 3986 keeps '~' literal, as Go does; '@', ':' and '/' are encoded on both sides.
        $message = $path . '?' . http_build_query($fields, '', '&', PHP_QUERY_RFC3986);
        $fields['signature'] = $this->digest($message);
        return $fields;
    }

    /** Sign a GET URL for reports.tulus.my. Returns the full URL with exp and signature. */
    public function signUrl(string $url): string
    {
        $u = parse_url($url);
        parse_str($u['query'] ?? '', $q);
        $q['exp'] = (string) (time() + 60);
        ksort($q);
        $message = $u['path'] . '?' . http_build_query($q, '', '&', PHP_QUERY_RFC3986);
        return $u['scheme'] . '://' . $u['host'] . $message . '&signature=' . $this->digest($message);
    }

    /** Verify a direct callback. Pass the raw POST fields (e.g. $request->post()). */
    public function verifyCallback(array $post): bool
    {
        $received = (string) ($post['signature'] ?? '');
        if ($received === '') {
            return false;
        }
        unset($post['signature']);
        ksort($post);
        $parts = [];
        foreach ($post as $k => $v) {
            // urlencode == Go url.QueryEscape for every byte a callback can contain (space -> '+').
            $parts[] = urlencode((string) $k) . '=' . urlencode((string) $v);
        }
        $expected = $this->digest(implode('&', $parts));
        return hash_equals($expected, strtolower($received));
    }

    private function digest(string $message): string
    {
        return substr(hash_hmac('sha256', $message, $this->key), 0, 16);
    }
}
```

Register it once in `AppServiceProvider::register()`:

```php
$this->app->singleton(\App\Services\Tulus\TulusSigner::class,
    fn () => new \App\Services\Tulus\TulusSigner(config('tulus.api_key')));
```

### Test vectors (computed by the gateway's own Go code, key `DACA31D4-869A-4323-9455-E4F533EB08DC`)

Write these as a PHPUnit/Pest test before anything else. If they fail, nothing
downstream will work.

**Direct callback verification.** Fields:

```
status=success  orderNo=SNAPNPAYd8d7674c50  refNo=ORDER123  amount=2.50  addCharge=1.00
fpxTxnId=2503061436060451  fpxTxnTime=20250306143605  agency=SNAPNPAY  extraData=881225-08-1234
fpx={"fpx_seller_order_no":"SNAPNPAYd8d7674c50","fpx_debit_auth_code":"00"}
```

Signed message (sorted, URL-encoded, no `signature`):

```
addCharge=1.00&agency=SNAPNPAY&amount=2.50&extraData=881225-08-1234&fpx=%7B%22fpx_seller_order_no%22%3A%22SNAPNPAYd8d7674c50%22%2C%22fpx_debit_auth_code%22%3A%2200%22%7D&fpxTxnId=2503061436060451&fpxTxnTime=20250306143605&orderNo=SNAPNPAYd8d7674c50&refNo=ORDER123&status=success
```

Expected `signature`: **`1fed398f127653fd`**

**Checkout signing.** Fields `agency=snapnpay`, `refNo=ORDER123~881225-08-1234`,
`amount=1.00`, `email=payer@example.com`,
`returnUrl=https://shop.example.com/tulus/return`, and a fixed `exp=1800000000`
(freeze `time()` in the test). Signed message:

```
/v2/checkout?agency=snapnpay&amount=1.00&email=payer%40example.com&exp=1800000000&refNo=ORDER123~881225-08-1234&returnUrl=https%3A%2F%2Fshop.example.com%2Ftulus%2Freturn
```

Expected `signature`: **`8ba7befa130c872b`**

## 4. Storage

Create one table that is the **only** source of truth for whether an order is
paid. Both callback flavours write to it; the receipt page reads from it.

```bash
php artisan make:migration create_tulus_payments_table
```

```php
Schema::create('tulus_payments', function (Blueprint $table) {
    $table->id();
    $table->string('ref_no', 255)->unique();          // your refNo, including any ~extraData
    $table->string('order_no', 255)->nullable()->index(); // Tulus orderNo (AGENCY + hash)
    $table->string('agency', 32);
    $table->decimal('amount', 12, 2);                  // net amount you asked for
    $table->decimal('add_charge', 12, 2)->default(0);  // platform fee, from the direct callback
    $table->string('status', 16)->default('created');  // created | pending | success | failed
    $table->string('fpx_txn_id', 32)->nullable();
    $table->string('fpx_txn_time', 14)->nullable();    // compact YYYYMMDDHHmmss, as received
    $table->string('payment_method', 32)->nullable();  // fpx.b2c | fpx.b2b1 | mpgs | ...
    $table->json('fpx')->nullable();                   // the parsed fpx JSON from the direct callback
    $table->text('extra_data')->nullable();
    $table->timestamp('direct_received_at')->nullable();
    $table->timestamp('indirect_seen_at')->nullable();
    $table->timestamp('fulfilled_at')->nullable();
    $table->timestamps();
});
```

## 5. The payment page

Route and controller. The page renders a self-submitting form; the shopper
never sees the signed fields.

`routes/web.php`:

```php
use App\Http\Controllers\Tulus\CheckoutController;
use App\Http\Controllers\Tulus\ReturnController;

Route::get('/pay/{ref}', [CheckoutController::class, 'show'])->name('tulus.pay');
Route::post('/tulus/return', ReturnController::class)->name('tulus.return');
Route::get('/tulus/status/{ref}', [ReturnController::class, 'status'])->name('tulus.status');
```

`app/Http/Controllers/Tulus/CheckoutController.php`:

```php
<?php

namespace App\Http\Controllers\Tulus;

use App\Http\Controllers\Controller;
use App\Models\TulusPayment;
use App\Services\Tulus\TulusSigner;

class CheckoutController extends Controller
{
    public function show(string $ref, TulusSigner $signer)
    {
        // Load your own order here; the amount MUST come from your records, never from the request.
        $order = \App\Models\Order::where('ref', $ref)->firstOrFail();

        $refNo = $order->ref;                       // no '~' allowed
        if ($order->customer_ic) {                  // optional extraData, appended with '~'
            $refNo .= '~' . $order->customer_ic;
        }

        TulusPayment::firstOrCreate(
            ['ref_no' => $refNo],
            ['agency' => strtoupper(config('tulus.agency')), 'amount' => $order->total, 'status' => 'created']
        );

        $fields = $signer->signCheckout([
            'agency'    => config('tulus.agency'),
            'refNo'     => $refNo,
            'amount'    => number_format((float) $order->total, 2, '.', ''), // "12.34"
            'email'     => $order->customer_email,       // receipt goes here
            'returnUrl' => config('tulus.return_url'),
        ]);

        return view('tulus.checkout', [
            'action' => config('tulus.checkout_url'),
            'fields' => $fields,
        ]);
    }
}
```

`resources/views/tulus/checkout.blade.php`:

```blade
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Redirecting to payment</title></head>
<body onload="document.forms[0].submit()">
  <p>Redirecting you to the payment page&hellip;</p>
  <form method="post" action="{{ $action }}">
    @foreach ($fields as $name => $value)
      <input type="hidden" name="{{ $name }}" value="{{ $value }}">
    @endforeach
    <noscript><button type="submit">Continue to payment</button></noscript>
  </form>
</body>
</html>
```

Notes:

- Do **not** put `@csrf` (or any other extra input) in this form. The gateway
  computes its check over **every** posted field except `signature`, so a
  `_token` you did not sign turns the request into a 403.
- `exp` is 60 seconds. Render the form at the moment of redirect, not earlier.
  A cached or bookmarked checkout page fails with HTTP 403.
- In production Tulus **rejects unsigned or wrongly signed checkouts with
  HTTP 403**. A 403 here means the signer or the key is wrong, not the order.
- Mobile parity is a Tulus requirement for its own pages; your checkout page is
  a redirect and needs no layout.

## 6. The return URL: one route, two flavours

`ReturnController` handles both POSTs. It tells them apart by the presence of
`signature`.

### 6.1 CSRF exemption

Both POSTs come from outside your session. Exempt the route.

Laravel 11/12, `bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['tulus/return']);
})
```

Laravel 10: add `'tulus/return'` to `$except` in `App\Http\Middleware\VerifyCsrfToken`.

### 6.2 Controller

`app/Http/Controllers/Tulus/ReturnController.php`:

```php
<?php

namespace App\Http\Controllers\Tulus;

use App\Http\Controllers\Controller;
use App\Models\TulusPayment;
use App\Services\Tulus\TulusSigner;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ReturnController extends Controller
{
    public function __invoke(Request $request, TulusSigner $signer)
    {
        if ($request->isMethod('get')) {
            // The gateway never sends a GET. Show a neutral page and log it: it means a
            // misconfiguration on either side, not a payment result.
            Log::warning('tulus.return: unexpected GET', ['query' => $request->query()]);
            return response()->view('tulus.receipt', ['payment' => null, 'flavour' => 'unknown'], 200);
        }

        $post = $request->post();
        Log::info('tulus.return: received', ['flavour' => isset($post['signature']) ? 'direct' : 'indirect', 'refNo' => $post['refNo'] ?? null]);

        return isset($post['signature'])
            ? $this->direct($post, $signer)
            : $this->indirect($post);
    }

    /** Direct (signed, server-to-server). Authoritative. Must answer 2xx quickly or it is retried. */
    private function direct(array $post, TulusSigner $signer)
    {
        if (! $signer->verifyCallback($post)) {
            Log::warning('tulus.return: bad signature', ['refNo' => $post['refNo'] ?? null]);
            return response('invalid signature', 403);
        }

        $status = $post['status'] ?? '';
        if (! in_array($status, ['success', 'pending', 'failed'], true)) {
            return response('unknown status', 400);
        }

        $fpx = json_decode($post['fpx'] ?? '', true) ?: [];

        DB::transaction(function () use ($post, $status, $fpx) {
            $payment = TulusPayment::where('ref_no', $post['refNo'])->lockForUpdate()->first();
            if (! $payment) {
                // A callback for a refNo you never created: record it, do not fulfil.
                Log::warning('tulus.return: unknown refNo', ['refNo' => $post['refNo']]);
                return;
            }

            // Belt and braces: the signed amount must equal what you asked for.
            if (bccomp((string) $payment->amount, (string) $post['amount'], 2) !== 0) {
                Log::error('tulus.return: amount mismatch', ['expected' => $payment->amount, 'got' => $post['amount']]);
                return;
            }

            // Terminal states never go backwards: a late 'pending' after 'success' is ignored.
            if ($payment->status === 'success' && $status !== 'success') {
                return;
            }

            $payment->fill([
                'order_no'           => $post['orderNo'] ?? $payment->order_no,
                'agency'             => $post['agency'] ?? $payment->agency,
                'add_charge'         => $post['addCharge'] ?? 0,
                'status'             => $status,
                'fpx_txn_id'         => $post['fpxTxnId'] ?: null,
                'fpx_txn_time'       => $post['fpxTxnTime'] ?: null,
                'payment_method'     => $fpx['payment_method'] ?? null,
                'fpx'                => $fpx ?: null,
                'extra_data'         => $post['extraData'] ?? null,
                'direct_received_at' => now(),
            ])->save();

            // Idempotent fulfilment: the same success callback can arrive more than once
            // (monitor retries, and staff can re-send it from the merchant dashboard).
            if ($status === 'success' && $payment->fulfilled_at === null) {
                app(\App\Services\OrderFulfilment::class)->fulfil($payment); // your code
                $payment->forceFill(['fulfilled_at' => now()])->save();
            }
        });

        return response('OK', 200);
    }

    /** Indirect (browser, unsigned). Display only. Never trust its values. */
    private function indirect(array $post)
    {
        $payment = TulusPayment::where('ref_no', $post['refNo'] ?? '')->first();
        if ($payment) {
            $payment->forceFill(['indirect_seen_at' => now()])->save();
        }
        // Render from the database, not from $post. If the direct callback has not landed
        // yet, the page shows "processing" and polls /tulus/status/{ref} until it has.
        return response()->view('tulus.receipt', ['payment' => $payment, 'flavour' => 'indirect'], 200);
    }

    /** JSON for the receipt page to poll while the direct callback is in flight. */
    public function status(string $ref)
    {
        $payment = TulusPayment::where('ref_no', $ref)->firstOrFail();
        return response()->json(['status' => $payment->status, 'confirmed' => $payment->direct_received_at !== null]);
    }
}
```

### 6.3 Receipt view (indirect)

`resources/views/tulus/receipt.blade.php` shows one of three states from the
**database** row: `success` (paid), `failed`, or anything else (`created` /
`pending`, "we are confirming your payment") with a small script that polls
`/tulus/status/{ref}` every 3 seconds for up to two minutes and reloads on
`confirmed`. The direct callback usually arrives within seconds of the
indirect return, sometimes before it.

Do not print the POSTed `amount` as "amount paid": in the indirect return it is
the **gross** figure (net + `addCharge`), while the direct callback's `amount` is
**net**. Show your own stored amount and, if you charge the fee to the buyer,
`add_charge` from the direct callback.

### 6.4 Field differences between the two POSTs

| Field | Direct (signed) | Indirect (browser) |
|---|---|---|
| `signature` | present, 16 hex chars | absent |
| `status` | `success`, `pending`, `failed` | `success`, `failed` only |
| `amount` | net (excludes fee), 2 decimals | **gross** (includes fee) |
| `addCharge` | `0.00` style | plain decimal (`0`, `1`, `0.5`) |
| `extraData` | always present (may be empty) | **omitted** when empty |
| `fpx` | JSON string, always | only for agencies with JSON injection enabled |
| `fpxTxnTime` | compact `YYYYMMDDHHmmss` | same |
| `agency` | uppercase | uppercase |

Inside the `fpx` JSON, two timestamps use two formats and both are contractual:
`fpx_seller_txn_time` is `YYYY-MM-DD HH:MM:SS` Malaysia time, while
`fpx_fpx_txn_time` is compact `YYYYMMDDHHmmss`. Parse each accordingly; do not
normalise one to the other on the assumption that one of them is a bug.

`status` mapping from `fpx.fpx_debit_auth_code`: `00` is `success`; empty,
`09`, `76`, `99` are `pending`; everything else is `failed`. The full table is
at <https://developers.tulus.my/sdk/callback>.

## 7. Reconciliation (pending orders and missed callbacks)

A `pending` direct callback is followed later by `success` or `failed`. If your
server was down, the monitor retries the direct callback, but add a scheduled
check anyway:

```php
// app/Console/Commands/TulusReconcile.php (schedule every 10 minutes)
$url = $signer->signUrl(config('tulus.report_url') . '/api/report/v1/fpxrequest?refno=' . rawurlencode($payment->ref_no) . '&agency=' . config('tulus.agency'));
$res = Http::get($url)->json();   // {"status":"OK","fpxrequest":{... "fpx_debit_auth_code":"00", "payment_status":"success" ...}}
```

Map `fpxrequest.payment_status` exactly as the callback `status` and run the
same idempotent fulfilment path. Treat `NOTFOUND` as "not paid".

## 8. Local development and testing

- The return URL must be reachable from the internet over HTTPS. Use a tunnel
  (`cloudflared tunnel --url http://localhost:8000` or ngrok) and put the tunnel
  URL in `TULUS_RETURN_URL`. Private and loopback addresses are rejected by the
  gateway in production.
- **Live test payments must end in `12.34`** (for example `RM 12.34`) so that
  anyone reconciling later can tell they were deliberate tests.
- To replay a real callback against your local receiver, a Tulus operator can
  open the transaction in the merchant dashboard (Transactions, "CB Logs"), expand
  an attempt and copy its `curl` command. It re-sends the exact signed body, so
  your verifier must accept it as-is. Paste it into a terminal or import it into
  Postman (File, Import, Raw text).
- Unit-test the signer with the vectors in section 3 before doing anything
  online. Freeze `time()` (Carbon test-now does not affect `time()`; inject a
  clock or pass `exp` explicitly in the test).
- Feature-test the return route with both flavours: a signed POST that fulfils
  once and stays idempotent on repeat, a tampered POST that gets 403, an unsigned
  POST that renders the receipt without changing `status`, and a GET that
  renders a neutral page.

## 9. Delivery checklist for the agent

- [ ] `config/tulus.php` and `.env` keys; API key not committed.
- [ ] `TulusSigner` with both test vectors passing.
- [ ] `tulus_payments` migration and model, `ref_no` unique.
- [ ] `GET /pay/{ref}` rendering the auto-submit form with `exp` and `signature`.
- [ ] `POST /tulus/return` CSRF-exempt; direct branch verifies, upserts under a row lock, fulfils once on `success`, answers `200`.
- [ ] Indirect branch renders from the DB and polls `/tulus/status/{ref}`; never fulfils.
- [ ] Amount cross-check and "terminal state never regresses" guard in place.
- [ ] Every inbound callback logged with flavour, `refNo`, `status` (not the API key, which never appears anyway).
- [ ] Reconcile command scheduled, using the signed Report API URL.
- [ ] Manual end-to-end run with a `12.34` test amount; receipt shows paid only after the direct callback.

## 10. Gotchas, in the order people hit them

1. HMAC over the ASCII UUID instead of its 16 raw bytes. The vectors catch it.
2. Full 64-character digest instead of the first 16. The vectors catch it.
3. Adding `exp` when verifying a callback, or forgetting it when signing a checkout.
4. Including the URL path when verifying a callback (it is only part of outbound messages).
5. `@csrf` in the checkout form: the unsigned `_token` field is included in the
   gateway's check and the checkout fails with 403. And CSRF middleware on the
   return route: Tulus gets 419 and keeps retrying.
6. Fulfilling from the indirect return. It is unsigned and from the shopper's browser.
7. Treating the indirect `amount` as net. It is gross.
8. Non-idempotent fulfilment: the same `success` can be delivered more than once.
9. `~` in `refNo`. It is the reserved delimiter for `extraData`.
10. Responding slowly or with a redirect to the direct callback. Answer `200 OK` in under a few seconds; do heavy work in a queued job after committing the row.
