If you build SaaS for the Malaysian market, sooner or later you need FPX — and BayarCash is one of the gateways you'll reach for. The official SDK (webimpian/bayarcash-php-sdk) does its job. The problem is never the SDK; it's what happens when you call it straight from a controller, hardcode status codes into a Blade view, and discover in production that BayarCash retries your webhook 40 times because you returned a 500.
This is the pattern I've settled on after shipping BayarCash across three production Laravel apps — event ticketing, a support desk with SaaS billing, and a subscription product. Same gateway, three domains, one shape that held up every time.
What BayarCash actually is
Get the vocabulary right; half the integration bugs come from conflating these:
-
Portal — a merchant-level checkout config. You get a
portal_key. One per deployment. - API token (PAT) — authenticates outgoing API calls.
- Secret key — signs your request payloads (the checksum) and verifies webhook callbacks. Different from the token; keep both.
-
Payment intent — you POST a charge, BayarCash returns a hosted-checkout
url, you redirect the customer. -
Channel — use the SDK constants, never raw ints:
Bayarcash::FPX(1),FPX_DIRECT_DEBIT(3),DUITNOW_QR(6).
Sandbox console: console.bayarcash-sandbox.com → API Settings. Build the whole flow there before touching live money.
Install and configure
composer require webimpian/bayarcash-php-sdk
Credentials belong in the environment, resolved in one place:
BAYARCASH_ENV=sandbox
BAYARCASH_API_TOKEN=
BAYARCASH_API_SECRET_KEY=
BAYARCASH_PORTAL_KEY=
BAYARCASH_API_VERSION=v3
// config/bayarcash.php
return [
'env' => env('BAYARCASH_ENV', 'sandbox'),
'token' => env('BAYARCASH_API_TOKEN'),
'secret' => env('BAYARCASH_API_SECRET_KEY'),
'portal_key' => env('BAYARCASH_PORTAL_KEY'),
'api_version' => env('BAYARCASH_API_VERSION', 'v3'),
'callback_url'=> env('BAYARCASH_CALLBACK_URL'),
];
The one rule: wrap, don't replace
Your domain code must never see a BayarCash payload. The SDK handles HTTP, signing, and retries. Your job is to translate between your domain (Orders, Subscriptions) and the gateway, in exactly one layer. That gives three:
- A thin client wrapper — resolves config once, exposes typed methods, is your test mock seam.
- A gateway implementing a contract, speaking your domain in and out.
- A factory/manager that picks the active gateway, so BayarCash is swappable with Stripe/mock/manual.
Layer 1 — the client wrapper
class BayarCashClient
{
public function __construct(
private readonly Bayarcash $sdk,
private readonly string $secretKey,
private readonly string $portalKey,
) {}
public static function fromConfig(): self
{
$sdk = new Bayarcash((string) config('bayarcash.token'));
$sdk->setApiVersion((string) config('bayarcash.api_version', 'v3'));
if (config('bayarcash.env') === 'sandbox') {
$sdk->useSandbox();
}
return new self($sdk, (string) config('bayarcash.secret'), (string) config('bayarcash.portal_key'));
}
public function portalKey(): string { return $this->portalKey; }
public function createPaymentIntent(array $data): object { return $this->sdk->createPaymentIntent($data); }
public function checksum(array $data): string { return $this->sdk->createPaymentIntentChecksumValue($this->secretKey, $data); }
public function verifyTransactionCallback(array $c): bool { return $this->sdk->verifyTransactionCallbackData($c, $this->secretKey); }
}
Layer 2 — a contract your app speaks
interface BillingProvider
{
public function createCheckoutIntent(Workspace $ws, Plan $plan, string $cycle, string $returnUrl): CheckoutIntent;
/** Returns null when the signature is invalid — caller responds 401. */
public function verifyWebhook(array $payload): ?WebhookEvent;
}
Notice what the interface doesn't mention: no status, no portal_key, no arrays of gateway fields. That's the whole point.
Layer 3 — bind it, with a Fake fallback
$this->app->singleton(BillingProvider::class, function (): BillingProvider {
// No token (self-hosted install, or the test suite) => in-memory fake.
return (string) config('bayarcash.token') === ''
? new FakeBillingProvider
: BayarCashClient::fromConfig();
});
Binding a Fake when there's no token is what lets self-hosted builds run with billing off, and your entire test suite run without a network. It pays for itself immediately.
Creating a checkout
Build the payload in domain terms, sign it, send it, redirect:
$payload = array_filter([
'portal_key' => $this->client->portalKey(),
'order_number' => $externalId, // correlation key — max 30 chars!
'amount' => $this->amountForCycle($plan, $cycle), // "49.00"
'payer_name' => $ws->name,
'payer_email' => $ws->owner?->email ?? throw new \RuntimeException('No owner email.'),
'payment_channel' => Bayarcash::FPX,
'return_url' => $returnUrl,
'callback_url' => (string) config('bayarcash.callback_url'),
], fn ($v) => $v !== null && $v !== '');
if (($secret = (string) config('bayarcash.secret')) !== '') {
$payload['checksum'] = $this->client->checksum($payload);
}
$response = $this->client->createPaymentIntent($payload);
return redirect($response->url); // hosted checkout
Two things to stop on:
order_number is your correlation key — the only field that travels with the payment and comes back in every callback. BayarCash caps it at 30 characters, so encode just enough to correlate and keep it unique on retries:
// worst case "wau-{8}-business-a-{6}" == 30 chars exactly
return sprintf('wau-%s-%s-%s-%s',
substr($ws->uuid, 0, 8), $plan->tier->value, substr($cycle, 0, 1), bin2hex(random_bytes(3)));
Persist a pending local record before redirecting, stamped with external_id. If the webhook arrives before your redirect finishes (it happens), the record is already there to update.
Handling the callback and webhook
BayarCash talks back two ways: the server-to-server callback (callback_url, authoritative — your source of truth) and the browser return URL (return_url, a reconciliation safety net — run the same handler so a missed server callback still reconciles when the customer lands back).
CSRF-exempt the webhook route — the server POST has no session:
Route::post('webhooks/bayarcash', [BayarCashCallbackController::class, 'webhook'])
->withoutMiddleware([ValidateCsrfToken::class]);
Verify the signature first — BayarCash sends three payload shapes, each with its own verifier. Route by a marker field:
$verified = match (true) {
isset($payload['record_status']) => $sdk->verifyPreTransactionCallbackData($payload, $secret), // DD enrolment
isset($payload['status']) => $sdk->verifyTransactionCallbackData($payload, $secret), // payment
default => $sdk->verifyReturnUrlCallbackData($payload, $secret), // return URL
};
if ($verified !== true) { return null; } // caller returns 401
Then classify the integer status into your own vocabulary once, here — never let those ints escape:
$status = match ((int) $payload['status']) {
3 => PaymentStatus::Completed, // the one you care about
default => PaymentStatus::Failed,
};
Gotcha — verify the status map against current docs.
3 = successfulis stable; the failure/pending/cancelled numbers have moved between SDK generations. Pin the map for your API version and cover it with a test.
The golden webhook rule
public function webhook(Request $request): JsonResponse
{
$result = $this->gateway->handleWebhook($request);
if (! $result->processed) {
Log::channel('daily')->warning('BayarCash webhook ignored', ['message' => $result->message]);
}
// Return 200 even on a soft failure. BayarCash retries on non-2xx,
// so a 500 means it hammers you 40 times. Log, return OK, reconcile.
return response()->json(['processed' => $result->processed]);
}
This is the single most important production lesson here. Once you've verified the signature, respond 2xx. A bad signature is a 401. Everything else — order not found, unexpected status, a bug in your handler — logs loudly and still returns 200, because non-2xx puts you in BayarCash's retry loop and turns one problem into a storm. Make side effects idempotent (updateOrCreate, guard on status) so a retry is harmless.
The recurring-billing reality
Plain FPX is one-shot — no stored card, no automatic monthly charge. True auto-renewal needs an FPX Direct Debit mandate, which requires the payer's NRIC and phone — data a simple checkout usually doesn't collect. Two honest options: wire Direct Debit (createFpxDirectDebitEnrollment) and collect those fields, or charge one-shot and run a scheduled renewal job with lead-time + grace windows. Either way, keep supportsRecurring() returning false until DD is actually enrolled. And refunds: FPX has none automatic — surface a "process manually" path, not a button that silently fails.
Testing without the network
Because the contract is bound to a Fake when no token is set, the whole flow tests offline:
it('activates a subscription on a successful webhook', function () {
$sub = Subscription::factory()->pending()->create(['external_id' => 'wau-abc12345-business-m-a1b2c3']);
post(route('billing.bayarcash.webhook'), ['order_number' => $sub->external_id, 'status' => 3])
->assertOk();
expect($sub->fresh()->status)->toBe(SubscriptionStatus::Active);
});
it('rejects a tampered webhook with 401', fn () =>
post(route('billing.bayarcash.webhook'), ['status' => 3, 'checksum' => 'wrong'])->assertUnauthorized());
Add a dev-checkout route that fakes the hosted page (404'd in production) so you can click through /billing → checkout → return locally with zero credentials.
Production checklist
- Return 2xx after verifying — anything else triggers the retry loop. 401 only for bad signatures.
- Idempotent side effects — retries and the return-URL replay both re-enter the handler.
- CSRF-exempt the callback route.
- Public-reachable callback URL — no localhost, no basic-auth wall, no "block bots" edge rule swallowing it.
- Log every payload (behind a flag) to a daily channel — reconciliation is impossible without it.
- Queue post-payment side effects so the webhook responds fast.
-
SDK method-name gotcha: both
createPaymentIntentChecksumValue(correct) and the legacy typocreatePaymentIntenChecksumValueexist. Use the former. -
Channel-constant gotcha:
DUITNOW_QR = 6;3 = FPX_DIRECT_DEBIT. Hand-map integers and you'll charge someone through the wrong rail — use theBayarcash::*constants.
Wrap-up
The SDK is maybe 20% of a BayarCash integration. The other 80% is the architecture: a thin wrapper so credentials resolve once, a domain contract so your app never touches a raw payload, a factory so the gateway is swappable, a Fake so everything is testable, and a webhook handler that survives retries. Get those right and adding DuitNow QR, bolting on Stripe, or extracting the whole thing into a reusable cashier-bayarcash-style package becomes a rename, not a rewrite.
Payment integrations touch real money — test the status map, signature verification, and idempotency hardest, and always validate against BayarCash's current official docs for your API version.

Top comments (0)