alo-game / paymentsdk
Server-side SDK for game backends integrating with Alogame payment flows. Handles signature verification, timestamp freshness, and request/response envelopes so a partner only implements a handful of business hooks. Ships WebPay (co-pub, HMAC) and Expub (exclusive/direct-publishing, MD5, covers web
Requires
- php: >=8.1
- ext-json: *
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Server-side SDK for a game backend integrating with Alogame's payment flows. Ships two independent modules today:
Alogame\PaymentSdk\WebPay— the HMAC-SHA256 contract for co-publishing (co-pub) games. See Usage.Alogame\PaymentSdk\Expub— the MD5 contract for exclusive/direct- publishing (expub) games, covering both web top-up and Mobile IAP (they sharecreateOrder_url/exchange_url). See Expub.
Pick the module matching your game's publishing model — the two are not interchangeable and not versions of each other; see each module's own section for which applies to you. Either way the SDK handles signature verification, timestamp freshness, JSON parsing, and the exact response shape Alogame expects — a game backend only implements a handful of business hooks and never touches raw HTTP, signing, or field names.
Why this exists
Every WebPay integration on Alogame's side is driven by
api-game's
generic adapter, which calls out to a game's own backend for three things —
checkUid, createOrder, paymentReceived — always initiated by Alogame,
never by the game. A player's top-up always starts on the Alogame Portal,
not on the game's server — a game backend only ever reacts, it never
calls anything on Alogame.
Without this SDK, a game backend hand-writes an HTTP endpoint against
Alogame's contract from a doc — and the exact field names / signing scheme
are easy to get subtly wrong in ways that only surface once a real player's
order fails (see og029-webpay-standard-migration.md for a real example).
This SDK makes that class of bug structurally impossible: the wire contract
lives in code, not in a doc a human re-implements by hand.
Setup steps (what you, the game dev, actually do)
- Get your credentials from Alogame — a shared HMAC
secret_key(the only credentialWebpayHandleractually takes;game_codenever appears on the wire or in this SDK's API, so there's nothing to get for it), plus which environment (dev/staging vs prod) will call you first. You don't generate the secret; Alogame issues it. - Install the SDK —
composer require alo-game/paymentsdk. - Implement
WebpayHookInterface— writeonCheckUid,onCreateOrder,onPaymentReceivedagainst your own player/order data. See Usage. This is the only business logic you write. - Expose three routes on your own backend, each calling the matching
WebpayHandler::handle*()method. See the wiring example below — same three calls regardless of framework. - Make the URL publicly reachable by Alogame (not
localhost, not an internal-only address), then send Alogame Ops the base URL + the three paths. This is the only thing you hand back — you never configure anything on Alogame's side yourself. - Alogame Ops wires it into Console (WebPay Config → Endpoints: checkUid/createOrder/paymentReceived paths, strategy = HMAC-SHA256, your secret from step 1). Nothing for you to do here — just confirm the values back if asked.
- Alogame tests against your endpoint before going live, using Console's "Test API — check UID" tool with a real or sandbox UID you provide. Expect a few round-trips fixing anything that doesn't match — this is normal and exactly what step 7 is for.
- Go live — Alogame flips "Active Webpay Config" on. From this moment
every call is real player traffic. Make sure
onCreateOrderandonPaymentReceivedare safe to receive twice (Alogame retries automatically on a timeout) before this step.
Install
composer require alo-game/paymentsdk
No auth needed — this package is published on
Packagist via a
public mirror at github.com/alo-game/alogame-paymentsdk-php. This
GitLab repository stays the private source of truth (development, CI,
merge requests); the GitHub mirror only ever receives already-tested
tagged releases — see Publishing a release if
you're on the Alogame side cutting one.
The flow
Player Alogame Portal Alogame API Your backend (this SDK)
│ 1. pick package │ │ │
├─────────────────────────▶ │ │
│ │ 2. checkUid │ │
│ ├─────────────────────▶ 3. HTTP POST ─────────▶ WebpayHandler::handleCheckUid()
│ │ │ │ → your onCheckUid() hook
│ │ │ ◀─────────────────────┤
│ │ 4. createOrder │ │
│ ├─────────────────────▶ 5. HTTP POST ─────────▶ WebpayHandler::handleCreateOrder()
│ │ │ │ → your onCreateOrder() hook
│ │ │ ◀─────────────────────┤
│ 6. pays (napas/momo/…) │ │ │
├──────────────────────────┼─────────────────────▶ │
│ │ │ 7. HTTP POST ─────────▶ WebpayHandler::handlePaymentReceived()
│ │ │ │ → your onPaymentReceived() hook
│ │ │ ◀─────────────────────┤ (you deliver the item here)
│ │ ◀───────────────── │ │
│ ◀───────────────────────┤ "Top-up successful"│ │
Your backend never initiates any of this — it only implements the three hooks and waits.
Usage
Implement the one interface:
use Alogame\PaymentSdk\WebPay\Contracts\WebpayHookInterface; use Alogame\PaymentSdk\WebPay\Dto\{ CheckUidRequest, CheckUidResult, CreateOrderRequest, CreateOrderResult, PaymentReceivedRequest, PaymentReceivedResult, }; final class MyGameHooks implements WebpayHookInterface { public function onCheckUid(CheckUidRequest $request): CheckUidResult { $player = MyPlayerRepository::findByUid($request->uid); return $player ? CheckUidResult::found($player->nickname, $player->serverName) : CheckUidResult::notFound(); } public function onCreateOrder(CreateOrderRequest $request): CreateOrderResult { // $request->orderId is Alogame's own reference — store it so // onPaymentReceived can match this same order back to it. $order = MyOrderRepository::create( alogameOrderId: $request->orderId, uid: $request->uid, productId: $request->productId, amount: $request->amount, ); return CreateOrderResult::created($order->id); } public function onPaymentReceived(PaymentReceivedRequest $request): PaymentReceivedResult { $order = MyOrderRepository::findByOwnId($request->orderCode); if ($order === null) { return PaymentReceivedResult::failed('unknown order'); } MyInventory::deliver($order->uid, $order->productId); return PaymentReceivedResult::ok(); } }
Wire three routes to it (framework-agnostic — this example is plain PHP;
Laravel/Symfony just wrap the same three calls). Log anything a hook throws
via onError, so a bug in your own code doesn't disappear silently — the
player-facing response is always a clean generic error either way, never a
raw stack trace:
use Alogame\PaymentSdk\WebPay\WebpayHandler; $handler = new WebpayHandler( secret: getenv('ALOGAME_WEBPAY_SECRET'), hooks: new MyGameHooks(), onError: fn (\Throwable $e) => MyLogger::error($e), ); // routes/webpay.php — wire these to whatever your framework/router calls // "POST /webpay/check-uid", "/webpay/create-order", "/webpay/payment-received" $response = $handler->handleCheckUid(getallheaders(), file_get_contents('php://input')); http_response_code($response->status); header('Content-Type: application/json'); echo json_encode($response->body);
Optional: multiple servers
Only implement this if your game runs multiple servers and a player must pick one before topping up. Alogame derives "does this game need a server picker" from whether your hooks object implements this interface — nothing to configure separately:
use Alogame\PaymentSdk\WebPay\Contracts\ServerListHookInterface; use Alogame\PaymentSdk\WebPay\Dto\{GetServerListRequest, ServerInfo}; final class MyGameHooks implements WebpayHookInterface, ServerListHookInterface { // ...onCheckUid/onCreateOrder/onPaymentReceived as above... public function onGetServerList(GetServerListRequest $request): array { return array_map( static fn ($server) => new ServerInfo($server->id, $server->name), MyServerRepository::all(), ); } }
Wire a fourth route to WebpayHandler::handleGetServerList(). A game that
doesn't implement this interface answers 404 there automatically — you
don't need an if for it.
Optional: several characters per UID
Only implement this if one uid of yours can own more than one character and the player must pick which one receives the top-up. Independent of the server list above — implement either, both, or neither. Alogame derives "does this game need a character picker" from whether your hooks object implements this interface:
use Alogame\PaymentSdk\WebPay\Contracts\CharacterListHookInterface; use Alogame\PaymentSdk\WebPay\Dto\{CharacterInfo, GetCharacterListRequest}; final class MyGameHooks implements WebpayHookInterface, CharacterListHookInterface { // ...onCheckUid/onCreateOrder/onPaymentReceived as above... public function onGetCharacterList(GetCharacterListRequest $request): array { return array_map( static fn ($character) => new CharacterInfo($character->id, $character->name), MyCharacterRepository::forUid($request->uid, $request->serverId), ); } }
Called only after onCheckUid has already accepted the uid, so there is no
not-found case to signal — returning [] just means "this uid owns no
character" and shows an empty picker rather than an error.
Wire a route to WebpayHandler::handleGetCharacterList(). The picked value
then arrives on the same onCreateOrder call as everything else, as
$request->characterId — deliver to that character rather than to whatever
the uid alone resolves to. It is null for every game without this
interface, so an existing integration sees no change.
Health check — "is my SDK actually listening?"
Wire the last route to WebpayHandler::handleHealthCheck(). Alogame calls
it the same way as every other request (signed, x-timestamp/x-signature
headers) — a 200 back proves two things at once: your endpoint is
reachable, and the secret Console has on file for you still matches what
your backend checks against. An unsigned ping would only ever prove the
first half, which is why this isn't a plain GET /health.
$response = $handler->handleHealthCheck(getallheaders(), file_get_contents('php://input'));
Response on success: {"errcode":0,"msg":"ok","sdk_version":"1.0.0"} — the
version lets Alogame support tell which contract version you're running
without asking you to paste your composer.lock.
Expub (Exclusive/Direct-Publishing games)
For expub games only — co-pub games use WebPay above instead. This is
the older of the two contracts (MD5, Signature header, errors reported via
HTTP status rather than an errcode field), matching the algorithm every
expub/legacy game provider already signs with on Alogame's side
(backend-api's md5_timestamp strategy). It covers both an expub
game's web top-up flow and its Mobile IAP purchases — the two channels share
createOrder_url/exchange_url entirely.
Implement the one interface:
use Alogame\PaymentSdk\Expub\Contracts\ExpubHookInterface; use Alogame\PaymentSdk\Expub\Dto\{ GetUserListRequest, UserCharacter, CheckUidRequest, CheckUidResult, CreateOrderRequest, CreateOrderResult, PaymentReceivedRequest, PaymentReceivedResult, }; final class MyExpubGameHooks implements ExpubHookInterface { public function onGetUserList(GetUserListRequest $request): array { // Every character linked to this Alogame account — the player // picks one on Alogame's side before topping up. Empty is valid // (account linked, no characters yet). return MyCharacterRepository::findAllByAlogameUserId($request->userId); } public function onCheckUid(CheckUidRequest $request): CheckUidResult { $character = MyCharacterRepository::findByUid($request->uid); return $character ? CheckUidResult::found($character->name, $character->server) : CheckUidResult::notFound(); } public function onCreateOrder(CreateOrderRequest $request): CreateOrderResult { // $request->osId is "ios"/"android" for a Mobile IAP purchase, null // for a web top-up — same method, branch on it if delivery differs. $existing = MyOrderRepository::findByAlogameOrderId($request->orderId); if ($existing !== null) { // Alogame retried — return the SAME order_code, never a new one. return CreateOrderResult::duplicate($existing->orderCode); } $order = MyOrderRepository::create( alogameOrderId: $request->orderId, uid: $request->uid, productId: $request->productId, ); return CreateOrderResult::created($order->id); } public function onPaymentReceived(PaymentReceivedRequest $request): PaymentReceivedResult { $order = MyOrderRepository::findByOwnId($request->orderCode); if ($order === null) { return PaymentReceivedResult::orderCodeNotFound(); } MyInventory::deliver($order->uid, $order->productId); return PaymentReceivedResult::ok(); } }
Wire four routes to it — same reasoning as WebPay above (this SDK is
framework-agnostic, so dispatching the right URL to the right method is the
one piece of plumbing left to you):
use Alogame\PaymentSdk\Expub\ExpubHandler; $handler = new ExpubHandler( secret: getenv('ALOGAME_EXPUB_SECRET'), hooks: new MyExpubGameHooks(), onError: fn (\Throwable $e) => MyLogger::error($e), ); $response = match (true) { $_SERVER['REQUEST_URI'] === '/expub/get-user-list' => $handler->handleGetUserList(getallheaders(), file_get_contents('php://input')), $_SERVER['REQUEST_URI'] === '/expub/check-uid' => $handler->handleCheckUid(getallheaders(), file_get_contents('php://input')), $_SERVER['REQUEST_URI'] === '/expub/create-order' => $handler->handleCreateOrder(getallheaders(), file_get_contents('php://input')), $_SERVER['REQUEST_URI'] === '/expub/payment-received' => $handler->handlePaymentReceived(getallheaders(), file_get_contents('php://input')), }; http_response_code($response->status); header('Content-Type: application/json'); echo json_encode($response->body);
There is no health-check route for this contract (unlike WebPay) — Alogame
doesn't call one for expub games today.
See examples/SampleExpubHooks.php and examples/expub-quickstart.php for
a runnable, in-memory version of all four calls, including the retry/
duplicate-order_id and Mobile-IAP-vs-web-top-up cases.
Development
composer install composer test # phpunit composer stan # phpstan, level 8
Publishing a release
GitLab (this repo) is the private source — GitHub is a release-only
mirror, same pattern and same script style as alogame-kyc-sdk's
scripts/deploy_ios_spm.sh. It's a manual script run from your own
machine, not a CI job — nothing reaches the public mirror without someone
actually running it.
- Bump
WebpayHandler::VERSION/ExpubHandler::VERSION(whichever module changed), add a matching entry toCHANGELOG.md. - Merge to
mainvia MR (CI'stestjob — phpunit + phpstan — must pass). ./scripts/deploy_github.sh— re-runs tests/phpstan locally, then copies the released file set, commits, tags, and pushes togithub.com/alo-game/alogame-paymentsdk-php. Refuses to run if the constant, the CHANGELOG entry, and the tag you're about to push don't all agree.- First release only: submit the GitHub repo on packagist.org — it auto-syncs on every future push via GitHub's webhook, no manual step after that.
Needs scripts/.env (gitignored, never committed) with
ALOGAME_RELEASE_GITHUB_TOKEN=github_pat_xxx — a fine-grained GitHub PAT,
resource owner alo-game, Contents + Administration read/write. The same
token already used for alogame-kyc-sdk works here too, as long as its
repo access list also covers alogame-paymentsdk-php (or is org-wide).
Roadmap
Expub's own Mobile IAP is already covered — see Expub
above; Expub\ExpubHandler::handleCreateOrder/handlePaymentReceived serve
both an expub game's web top-up and its IAP purchases today, both MD5.
Still not built: unifying co-pub's Mobile IAP onto WebPay's HMAC
contract. Mobile IAP for a co-pub game currently lives entirely in
backend-api (GenericSignatureGameAdapter + signatures/md5Timestamp.js)
— same MD5 algorithm as Expub above, but different field names
(order_id/productId/price vs WebPay's plat_order_num/productid/amount),
and a different success signal (a body field, not errcode).
Plan, agreed but not yet built:
- Add a new opt-in HMAC strategy to
backend-api'sGenericSignatureGameAdapter(alongside the existingmd5_timestamp/md5_timestamp_ms, per-game config — existing co-pub IAP games keep MD5 unchanged, only a game explicitly switched over uses the new one). Very few co-pub games run Mobile IAP at all today, so the blast radius of getting this wrong is small. - That new strategy sends the same field names
WebPayalready uses (plat_order_num,productid,amount, ...) plus one new optional field,channel(ios/androidfor IAP, absent orwebfor WebPay) — so a singleonCreateOrderhook can serve both flows by branching on$request->channel, instead of needing two separate handlers. - Only after that ships in
backend-apidoes this get folded intoWebPayhere — implementing it against a doc instead of the realmd5Timestamp.jsalgorithm would repeat the exact mismatch this SDK exists to prevent (seedocs/og029-webpay-standard-migration.mdin api-game).
See also
- Web Payment — PHP SDK — the public-facing integration guide on docs.alogame.vn (start here if you're integrating, not developing this package).
docs/og029-webpay-standard-migration.mdin api-game — the field-name contract this SDK implements verbatim, and the specific bugs it exists to make impossible.