Web Payment — PHP SDK
| Applies to | Co-publishing (Standard) or exclusive/direct-publishing (Expub) games with a PHP backend |
| Package | alo-game/paymentsdk on Packagist |
| Requires | PHP ≥ 8.1 |
| Implements | Web Payment — Standard, in full — plus Web Payment — Expub (including Expub's shared Mobile IAP) |
This page is everything you need to integrate WebPay in PHP. You do not need to read Web Payment — Standard or Web Payment — Expub first — those describe the raw HTTP contract for teams writing their own endpoint by hand. This SDK implements both contracts for you: correct field names, correct signature scheme, correct response shape, on every call. You write a handful of PHP functions and never touch HTTP, signing, or JSON directly.
The package ships two independent modules — pick the one matching your game's publishing model, not both by default:
- WebPay (below) — for co-publishing (co-pub) games, three hooks, HMAC-SHA256.
- Expub (below) — for exclusive/direct-publishing (expub) games, four hooks, MD5, and also covers that same game's Mobile IAP purchases.
Whichever module applies to you, it's the exact same wire contract every other game on that publishing model uses — same calls, same wire format. The SDK just means you never write that wire format by hand.
SDK vs. hand-implementing the contract
| Hand-implement the wire contract | This SDK | |
|---|---|---|
| Signing, timestamp check | You write and test it yourself | WebpayHandler/ExpubHandler does it, already tested |
Field names (plat_order_num, order_id, productid/productId, …) | Re-typed by hand from the doc — a subtly wrong field name here only surfaces once a real player's order fails | Typed DTO properties ($request->orderId, …) — a wrong field name is a PHP fatal, not a silent mismatch discovered in production |
Response envelope (errcode body vs. HTTP status) | You build the exact shape by hand, per contract | CheckUidResult/CreateOrderResult/PaymentReceivedResult build it for you |
| Business logic you write | Everything: parsing, signing, envelope, and your own player/order lookups | Only your own player/order lookups — the on... hooks |
| Routing calls to handlers | Same either way — PHP has no runtime router a package can hook into | Same either way |
| Idempotency (retry-safety) | Your responsibility either way — the SDK can't know what "already delivered" means in your database | Your responsibility either way |
The SDK removes the parts that are pure transcription risk — signature scheme, field names, response envelope — and leaves untouched the parts that were never mechanical to begin with: your own database lookups, and which route dispatches to which handler. It's a smaller, more type-checked surface to get right, not a smaller amount of code you have to think about.
Which module do I need?
Same rule as the contract picker: it depends on your game's publishing model, not on when it was integrated.
- Co-publishing (co-pub) game → the WebPay module. A player types their in-game UID by hand; no Alogame-linked account involved.
- Exclusive/direct-publishing (expub) game → the Expub module, for both its web top-up page and its Mobile IAP purchases — a player is already logged into their Alogame account, which is what lets Alogame fetch a character list instead of asking them to type a UID.
A game is one or the other, never both — if you're not sure which, ask your Alogame Operations contact before writing any code.
WebPay module (Web Payment — Standard)
The flow
Alogame always initiates every call below. A player's top-up starts on the Alogame Portal — your server never calls out to Alogame, it only responds.
Request & response reference
Everything below is what actually goes over the wire — the SDK builds and
parses all of it for you, but knowing the shape helps when you're reading
logs or debugging a mismatch with Alogame Operations. Every call is
POST, signed (x-timestamp + x-signature, see Authentication),
and must be answered within 8 seconds — a slower response counts as a
failure, and for onPaymentReceived that means a retry (see below).
onCheckUid — player enters their UID on the top-up screen
Wire endpoint: check_uid
CheckUidRequest property | Wire field | Type | Description |
|---|---|---|---|
$uid | uid | string | In-game character UID as typed by the player |
$serverId | server_id | string|null | Only present if you implement ServerListHookInterface |
$gameId | game_id | string|null | Only present for a partner Alogame splits into multiple store listings (rare — see ServerInfo/platform notes below) |
{ "uid": "100002078" }
Return CheckUidResult::found($nickname, $server) or CheckUidResult::notFound().
The SDK turns that into the wire response for you:
// found
{ "errcode": 0, "msg": "success", "data": { "nickname": "DragonSlayer", "server": "Server 1" } }
// not found
{ "errcode": 1, "msg": "user not found" }
Alogame detects a missing character by matching msg case-insensitively
against the substring "not found" or "not exist" — anything else is
shown to the player as a generic system error instead of "check your UID".
CheckUidResult::notFound() already returns compliant wording; you never
write this string yourself.
onCreateOrder — player confirmed a package, before payment UI
Wire endpoint: create_order
CreateOrderRequest property | Wire field | Type | Description |
|---|---|---|---|
$orderId | plat_order_num | string | Alogame's order code — store it, it's the correlation key onPaymentReceived uses to find this order again |
$uid | uid | string | Same UID onCheckUid already validated |
$productId | productid | string | Your own product ID, exactly as submitted to Alogame |
$amount | amount | integer | Order total, VND, no decimals |
$sandbox | sandbox | integer (0/1) | 1 in dev/staging, 0 in production — Alogame sets this automatically from environment, it's not something you configure |
$serverId | server_id | string|null | Only present if server-scoped |
$gameId | game_id | string|null | Rare — see the note under onCheckUid |
{
"uid": "100002078",
"plat_order_num": "260509161539010042",
"productid": "pack_500_gold",
"amount": 49000,
"sandbox": 0
}
Return CreateOrderResult::created($yourOwnOrderCode). $yourOwnOrderCode
is required — the SDK's wire response always includes it:
{ "errcode": 0, "msg": "success", "data": { "order_num": "PARTNER-ORD-20260315-001" } }
$request->orderIdIf Alogame retries create_order (network hiccup, timeout) the same
plat_order_num arrives twice. Return the same order_num you returned
the first time — don't create a second order for one plat_order_num.
onPaymentReceived — payment provider confirmed payment, grant the item now
Wire endpoint: payment_notify
PaymentReceivedRequest property | Wire field | Type | Description |
|---|---|---|---|
$orderId | plat_order_num | string | Alogame's order code, same value as onCreateOrder |
$orderCode | order_num | string | Your own order code, the one you returned from onCreateOrder |
$amount | amount | integer | Order total, VND |
$gameId | game_id | string|null | Rare — see the note under onCheckUid |
The wire body also carries a status field (always 1, "payment
successful") — the SDK doesn't surface it on PaymentReceivedRequest
because there is currently no other value it can take.
{
"plat_order_num": "260509161539010042",
"order_num": "PARTNER-ORD-20260315-001",
"amount": 49000,
"status": 1
}
Return PaymentReceivedResult::ok() after delivering the item, or
PaymentReceivedResult::failed($reason):
// ok
{ "errcode": 0 }
Anything other than PaymentReceivedResult::ok() — including your endpoint
timing out — schedules a retry. Alogame makes up to 4 attempts total:
the initial call, then retries after 60s, 120s, and 120s.
Match on $request->orderCode (or $request->orderId) and return ok()
for an order you've already delivered — never grant the item twice, and
never return failed() for an already-completed order, or the retry loop
keeps running against an order that's actually done.
Prerequisites
Before you write any code, get from your Alogame Operations contact:
- A shared HMAC
secret_key— the only credential this module actually uses (passed straight intoWebpayHandler's constructor).game_codeitself never appears in any request or response body, and the SDK has no parameter for it — Alogame's own Console config is what ties yoursecret_key/base URL to a game, not anything you send. - Which environment (dev/staging vs prod) will call you first
1. Install
Public package, no credentials needed:
composer require alo-game/paymentsdk
Source: github.com/alo-game/alogame-paymentsdk-php (MIT licensed, CHANGELOG).
2. Implement the three hooks
The only interface you implement. Alogame calls these — you never call Alogame:
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 (plat_order_num on
// the wire) — 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();
}
}
onPaymentReceived is retried up to 4 times if it doesn't answer ok
(including a timeout) — see Request & response reference
above for the exact schedule and what to match on. onCreateOrder has the
same requirement: a repeat with the same $request->orderId must return the
same orderCode you returned the first time, not create a second order.
3. Wire the routes
Alogame calls three separate URLs, not one — handleCheckUid,
handleCreateOrder and handlePaymentReceived each answer a different
call, so something has to decide, per incoming request, which one to
invoke. The SDK is deliberately framework-agnostic (it doesn't know
Laravel's router from a bare $_SERVER['REQUEST_URI'] check), so that
routing decision is the one piece of plumbing left to you — everything
inside each branch below is already fully handled by the method you call.
Plain PHP shown here; in Laravel/Symfony the match disappears and
becomes three route definitions instead, each calling the same handler
method:
use Alogame\PaymentSdk\WebPay\WebpayHandler;
$handler = new WebpayHandler(
secret: getenv('ALOGAME_WEBPAY_SECRET'),
hooks: new MyGameHooks(),
onError: fn (\Throwable $e) => error_log($e), // never let a bug leak to the wire
);
// Wire each to its own route — sign the raw body, never a re-parsed one
// (same rule as every other contract, see the Authentication page).
$response = match (true) {
$_SERVER['REQUEST_URI'] === '/webpay/check-uid' => $handler->handleCheckUid(getallheaders(), file_get_contents('php://input')),
$_SERVER['REQUEST_URI'] === '/webpay/create-order' => $handler->handleCreateOrder(getallheaders(), file_get_contents('php://input')),
$_SERVER['REQUEST_URI'] === '/webpay/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);
4. Optional: multiple servers
Only needed if your game runs multiple servers and a player must pick one before topping up. Implement a second interface — Alogame detects it automatically, 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 $handler->handleGetServerList(...). Skip this
section entirely if your game has one shared server.
5. Wire a health check
One more route, wired once — the fourth if you skipped step 4, the fifth if you didn't:
$response = $handler->handleHealthCheck(getallheaders(), file_get_contents('php://input'));
Alogame can call this at any time — signed, same as every other call — to confirm your endpoint is reachable and the secret on file still matches. Give this path to Alogame Operations alongside the other three.
6. Give Alogame your endpoint
Send Alogame Operations, per environment:
- Base URL + the four (or five, with the server list) paths
- Confirmation that the
secret_keyfrom Prerequisites is wired in
Alogame Operations configures Console on their side — you don't touch Console yourself.
Alogame's side of this contract is exactly what the Request & response
reference above describes: send the right
data, in the right format, to the path you gave us, and read back whatever
your response says — nothing more. What
onCheckUid/onCreateOrder/onPaymentReceived do with the data in
between (which database, which player, which order) is entirely internal to
your backend, and Alogame has no way to see or verify it.
Before going live, Alogame's Console test tool calls your check-uid path
with one UID you provide, and shows you the raw response. That only proves
the path is reachable and the response is shaped correctly for that one
call — it is not, and cannot be, a check that onCheckUid looks up an
arbitrary UID correctly against your real player data. That correctness is
entirely on you to verify before step 7, the same way you'd test any other
part of your own backend.
7. Go live
Alogame switches your config to active. From this point every call is real player traffic.
Expub module (Web Payment — Expub + Mobile IAP)
Covers both an expub game's web top-up page and its Mobile IAP
purchases — the two channels share createOrder_url/exchange_url
entirely, so the same two hooks answer both.
The flow
The player reaches this flow only after logging into their Alogame account — unlike WebPay's co-pub flow, there is no UID typed by hand.
Unlike WebPay's co-pub flow, Alogame's own checkout never calls check-uid
for an expub game — the player picked their character from onGetUserList's
own response, so uid existence is already proven by construction. Some
partners' backends have a standalone check-uid endpoint anyway (it predates
this SDK, or their own tooling uses it) — see onCheckUid below, which is
optional for exactly that reason.
Request & response reference
Every call is POST, signed (Signature header, MD5, timestamp in
seconds — see Authentication), and errors
are reported via HTTP status, not an errcode body field like WebPay.
onGetUserList — player opens the top-up screen
Wire endpoint: getUserList_url
GetUserListRequest property | Wire field | Type | Description |
|---|---|---|---|
$userId | userId | string (UUID) | Alogame account ID — every character linked to this account should come back |
$extInfo | ext_info | string|null | Context forwarded from the SDK |
{ "userId": "550e8400-e29b-41d4-a716-446655440000", "ext_info": "{}" }
Return an array of UserCharacter. An empty array is valid — an account
with no characters yet still returns 200:
{
"userId": "550e8400-e29b-41d4-a716-446655440000",
"uids": [
{ "uid": "char_001", "server": "Server 1", "characterName": "DragonSlayer" }
]
}
onCheckUid — OPTIONAL, only if your backend already has this endpoint
Wire endpoint: checkUser_url
CheckUidHookInterface only if you need itNot part of ExpubHookInterface. Alogame's own checkout flow never calls
this for an expub game (see the note under The flow above) —
ExpubHandler::handleCheckUid() answers 404 NOT_CONFIGURED unless your
hooks class also implements CheckUidHookInterface. Implement it only if
your backend already has a standalone check-uid endpoint for some other
reason (predates this SDK, your own tooling calls it, etc.) — e.g. oe-007's
?ac=check_uid.
CheckUidRequest property | Wire field | Type | Description |
|---|---|---|---|
$uid | uid | string | Character UID from the onGetUserList response |
$extInfo | ext_info | string|null | Context forwarded from the SDK |
{ "uid": "char_001", "ext_info": "{}" }
Return CheckUidResult::found($characterName, $server) or
CheckUidResult::notFound():
// found — 200 OK
{ "characterName": "DragonSlayer", "server": "Server 1" }
errcodeCheckUidResult::notFound() produces a real 404, not a 200 with an
error body — the opposite of WebPay's envelope above.
onCreateOrder — same hook serves web top-up and Mobile IAP
Wire endpoint: createOrder_url — shared with Mobile IAP
CreateOrderRequest property | Wire field | Type | Description |
|---|---|---|---|
$orderId | order_id | string | Alogame's order code — store it, onPaymentReceived matches back to it |
$uid | uid | string | Character UID from the onGetUserList response the player picked |
$productId | productId | string | Your own product ID |
$amount | price | int|null | Order total — branch on $osId to tell the channel apart, not on whether this is set |
$serverId | serverId | string|null | Present if the game is server-scoped |
$osId | os_id | string|null | "ios"/"android" for a Mobile IAP purchase, null for a web top-up — the field that tells the two channels apart |
$extInfo | ext_info | string|null | Context forwarded from the SDK |
{ "order_id": "alo_ord_1a2b3c4d", "uid": "char_001", "productId": "pack_500_gold", "ext_info": "{}" }
Return CreateOrderResult::created($yourOwnOrderCode):
// 201 Created
{ "status": "success", "order_code": "PARTNER-ORD-20260315-001" }
$request->orderIdIf Alogame retries, return CreateOrderResult::duplicate($existingOrderCode)
— the SDK answers 409 with the original order_code, never a new order.
onPaymentReceived — grant the item now
Wire endpoint: exchange_url — shared with Mobile IAP
PaymentReceivedRequest property | Wire field | Type | Description |
|---|---|---|---|
$orderCode | order_code | string | Your own order code, from onCreateOrder |
$extInfo | ext_info | string|null | Context forwarded from the SDK |
{ "order_code": "PARTNER-ORD-20260315-001", "ext_info": "{}" }
Return PaymentReceivedResult::ok() after delivering the item:
// 200 OK
{ "processingStatus": "completed" }
Return PaymentReceivedResult::alreadyProcessed() (409) for an order
you've already delivered — never grant twice, and never treat a repeat as
orderCodeNotFound().
Prerequisites
- A shared MD5
secretfrom your Alogame Operations contact — the only credentialExpubHandlertakes. - Confirmation that this game is expub, not co-pub — see Which module do I need? above if unsure.
1. Install
Same package as WebPay above — no separate install:
composer require alo-game/paymentsdk
2. Implement the three required hooks (+ check-uid if you already have one)
use Alogame\PaymentSdk\Expub\Contracts\ExpubHookInterface;
use Alogame\PaymentSdk\Expub\Dto\{
GetUserListRequest, UserCharacter,
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 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();
}
}
Same requirement as WebPay above: onCreateOrder must answer a repeated
$request->orderId with the same orderCode, and onPaymentReceived must
answer an already-delivered $request->orderCode with ok(), never a
second delivery.
3. Wire the routes
Four separate URLs, no health-check route for this contract (unlike WebPay) — Alogame doesn't call one for expub games today:
use Alogame\PaymentSdk\Expub\ExpubHandler;
$handler = new ExpubHandler(
secret: getenv('ALOGAME_EXPUB_SECRET'),
hooks: new MyExpubGameHooks(),
onError: fn (\Throwable $e) => error_log($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);
Wire the check-uid route unconditionally, whether or not you implement
CheckUidHookInterface — handleCheckUid() itself answers 404 NOT_CONFIGURED when your hooks class doesn't, which is the correct
response either way.
4. Optional: check-uid
Only needed if your backend already has a standalone check-uid endpoint
for some other reason — Alogame's own checkout never calls this for an
expub game (see The flow above). Implement a second
interface — ExpubHandler detects it automatically, nothing to configure
separately:
use Alogame\PaymentSdk\Expub\Contracts\CheckUidHookInterface;
use Alogame\PaymentSdk\Expub\Dto\{CheckUidRequest, CheckUidResult};
final class MyExpubGameHooks implements ExpubHookInterface, CheckUidHookInterface
{
// ...onGetUserList / onCreateOrder / onPaymentReceived as above...
public function onCheckUid(CheckUidRequest $request): CheckUidResult
{
$character = MyCharacterRepository::findByUid($request->uid);
return $character
? CheckUidResult::found($character->name, $character->server)
: CheckUidResult::notFound();
}
}
Skip this section entirely if your backend has no reason to expose one.
5. Give Alogame your endpoint
Send Alogame Operations, per environment, the base URL + all four paths,
plus confirmation that the secret from Prerequisites
is wired in. Alogame Operations configures Console on their side.
6. Go live
Alogame switches your config to active. From this point every call — both web top-up and Mobile IAP — is real player traffic.
Upgrading
Check the CHANGELOG
before running composer update — this package runs inside your
production backend, the same way any other dependency does. The two
modules version independently within the same package; the CHANGELOG notes
which one a given release actually touched.
Implementation checklist
WebPay module:
-
composer require alo-game/paymentsdksucceeds -
onCheckUid,onCreateOrder,onPaymentReceivedimplemented against your own data -
onCreateOrderandonPaymentReceivedare safe to receive the same order twice - Three (or four, with server list) routes wired, each passing the raw request body
- Health-check route wired and its path sent to Alogame Operations
- Base URL + paths + secret confirmation sent to Alogame Operations
- Passed Alogame's pre-go-live test call
Expub module:
-
composer require alo-game/paymentsdksucceeds -
onGetUserList,onCreateOrder,onPaymentReceivedimplemented against your own data -
onCheckUidimplemented +CheckUidHookInterfaceadded — only if your backend already has a standalone check-uid endpoint -
onCreateOrderhandles a repeatedorder_idasduplicate(), not a new order -
onPaymentReceivedhandles a repeatedorder_codeasalreadyProcessed(), not a second delivery - Four routes wired, each passing the raw request body
- Base URL + all four paths + secret confirmation sent to Alogame Operations
- Mobile IAP wired against the same
createOrder_url/exchange_url, if this game has a mobile app