Owner Postback (Vote Rewards)
Reward players automatically when they vote for your server. Follow the steps below to connect your endpoint, verify HMAC signature, and credit WCoin in your MS SQL database.
1) What you need to do (admin)
- On your hosting, create a public folder, for example
/muogg-postback/. - Upload two files there:
config.phpandpostback.php(examples below). - Make sure the final endpoint is reachable from the internet, for example
https://your-domain.com/muogg-postback/postback.php. - Open MUOGG → Account → Postback and save:
- Account Postback URL — the full URL to your uploaded
postback.php, - Postback Secret — the same value as
secretin yourconfig.php.
- Account Postback URL — the full URL to your uploaded
- Use Send Test Postback with a real account name from your MU database.
After a successful setup, players will automatically receive your configured currency for a vote. Amounts come from rewards in config.php.
2) Required settings
- Public URL: use a real
httporhttpsdomain. Localhost, private IPs and internal network URLs are not accepted. - HMAC Secret: must be 8-128 characters in MUOGG and must match your
config.phpexactly. - Database connection: enter the correct MS SQL host, port, database, username and password for your MU server.
- Cash table: set the table that stores player currency, for example
CashShopData. - Account field: set the column used to find the player account, usually
AccountID. - Currency column: choose the column to credit, for example
WCoinC,WCoinPorGoblinPoint. - Vote reward: set the amount for
vote. Set an event amount to0to log it without crediting coins.
3) What MUOGG sends
When a player votes, MUOGG sends a signed JSON POST request to your Postback URL.
event— currentlyvotefor real vote rewards.server_id— the server ID from MUOGG.success— must be true for a reward to be credited.username— the main value to use as the MU account/nickname from the vote form.account,characterandnickname— compatibility aliases with the same player name.user_id— MUOGG user ID when available. It can be0, so do not use it as the primary MU account field.ipandvoted_at— vote IP and UTC timestamp for logging.X-Signatureheader — HMAC-SHA256 signature. Your endpoint verifies it with the same secret.
The example endpoint accepts both the current fields and legacy aliases like userId and ts for compatibility.
4) Example config.php (upload to your hosting)
<?php
return [
'timezone' => 'Europe/Berlin',
'secret' => 'testsecret', // HMAC for X-Signature
// Connecting to the database
'db' => [
'driver' => 'pdo_dblib', // 'pdo_sqlsrv' | 'pdo_dblib'
'host' => '11.22.333.44',
'port' => 1433,
'dbname' => 'MuOnline',
'user' => 'sa',
'pass' => 'DBPassword',
'charset' => 'UTF-8',
],
// Tables
'tables' => [
'cash' => [
'table' => '[Louis].[dbo].[CashShopData]',
'account_field' => 'AccountID',
],
'log' => '[Louis].[dbo].[MMOPlusPostbackLog]',
],
// Which currency column to deposit into
'credit' => [
'column' => 'WCoinC', // WCoinC, WCoinP, GoblinPoint, ...
],
// Amounts to deposit
'rewards' => [
'vote' => 10, // reward for voting
'review' => 0, // disabled
'test' => 5, // test ping
],
// Where to read the account from in the JSON payload
// New format first, legacy fields kept for compatibility.
'account_from' => ['username', 'account', 'character', 'nickname', 'user_id', 'userId'],
// Optional IP allowlist
'ip_allow' => [
// '1.2.3.4',
],
];
Replace DB credentials and currency column/amounts as you need.
5) Example postback.php (upload to your hosting)
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$cfg = require __DIR__ . '/config.php';
date_default_timezone_set((string)($cfg['timezone'] ?? 'UTC'));
function out(int $code, array $data): never {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
out(405, ['ok'=>false,'error'=>'Method Not Allowed']);
}
/* Optional IP allowlist */
if (!empty($cfg['ip_allow']) && is_array($cfg['ip_allow'])) {
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? '');
if (!in_array($ip, $cfg['ip_allow'], true)) {
out(403, ['ok'=>false,'error'=>'Forbidden IP']);
}
}
/* Read JSON */
$raw = file_get_contents('php://input') ?: '';
$data = json_decode($raw, true);
if (!is_array($data)) out(400, ['ok'=>false,'error'=>'Invalid JSON']);
/* Fields */
$event = trim((string)($data['event'] ?? ''));
$server_id = (int)($data['server_id'] ?? 0);
$success = (bool)($data['success'] ?? false);
$userId = trim((string)($data['user_id'] ?? ($data['userId'] ?? '')));
$username = trim((string)($data['username'] ?? ''));
$account = trim((string)($data['account'] ?? ''));
$character = trim((string)($data['character'] ?? ''));
$nickname = trim((string)($data['nickname'] ?? ''));
$reqIp = (string)($data['ip'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
if ($event === '' || $server_id <= 0) out(422, ['ok'=>false,'error'=>'Missing fields (event/server_id)']);
if ($event !== 'test' && !$success) out(200, ['ok'=>true,'credited'=>false,'msg'=>'success=false']);
/* HMAC */
$secret = (string)($cfg['secret'] ?? '');
if ($secret !== '') {
$sigHeader = (string)($_SERVER['HTTP_X_SIGNATURE'] ?? '');
$calc = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($calc, $sigHeader)) out(401, ['ok'=>false,'error'=>'Bad signature']);
}
/* Resolve account */
$accountId = '';
foreach ((array)($cfg['account_from'] ?? ['username','account','character','nickname','user_id','userId']) as $f) {
if ($f === 'username' && $username !== '') { $accountId = $username; break; }
if ($f === 'account' && $account !== '') { $accountId = $account; break; }
if ($f === 'character' && $character !== '') { $accountId = $character; break; }
if ($f === 'nickname' && $nickname !== '') { $accountId = $nickname; break; }
if (($f === 'user_id' || $f === 'userId') && $userId !== '' && $userId !== '0') { $accountId = $userId; break; }
}
if ($accountId === '') out(422, ['ok'=>false,'error'=>'Missing account id (username/user_id)']);
/* Reward & credit column */
$amount = (int)(($cfg['rewards'] ?? [])[$event] ?? 0);
if ($amount < 0) $amount = 0;
$creditCol = preg_replace('~[^A-Za-z0-9_]+~', '', (string)($cfg['credit']['column'] ?? 'WCoinC'));
if ($creditCol === '') $creditCol = 'WCoinC';
/* Tables */
$tables = (array)($cfg['tables'] ?? []);
$cashCfg = (array)($tables['cash'] ?? []);
$cashTbl = (string)($cashCfg['table'] ?? '');
$accField = preg_replace('~[^A-Za-z0-9_]+~', '', (string)($cashCfg['account_field'] ?? 'AccountID'));
$logTable = (string)($tables['log'] ?? '[dbo].[MMOPlusPostbackLog]');
if ($cashTbl === '' || $accField === '') out(500, ['ok'=>false,'error'=>'Table configuration error']);
/* DB connect */
function make_pdo(array $db): PDO {
$driver = (string)($db['driver'] ?? 'pdo_sqlsrv');
$host = (string)($db['host'] ?? '127.0.0.1');
$port = (int) ($db['port'] ?? 1433);
$dbname = (string)($db['dbname'] ?? '');
$user = (string)($db['user'] ?? '');
$pass = (string)($db['pass'] ?? '');
$charset = (string)($db['charset']?? 'UTF-8');
$dsn = (string)($db['dsn'] ?? '');
if ($dsn === '') {
if ($driver === 'pdo_sqlsrv') $dsn = "sqlsrv:Server={$host},".(int)$port.";Database={$dbname}";
else $dsn = "dblib:host={$host}:".(int)$port.";dbname={$dbname};charset={$charset}";
}
return new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
/* Helpers */
function obj_name(string $x): string { return str_replace(['[',']'], '', $x); }
function log_has_note(PDO $pdo, string $logTable): bool {
$obj = obj_name($logTable);
$sql = "SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{$obj}') AND name = 'note'";
try { return (bool)$pdo->query($sql)->fetchColumn(); } catch(Throwable $e){ return false; }
}
try {
$pdo = make_pdo((array)($cfg['db'] ?? []));
/* Ensure log table */
$tblBare = basename(str_replace('.', '/', obj_name($logTable)));
$pdo->exec("
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = '{$tblBare}')
BEGIN
CREATE TABLE {$logTable}(
id BIGINT IDENTITY(1,1) PRIMARY KEY,
server_id INT NOT NULL,
event NVARCHAR(24) NOT NULL,
account_id NVARCHAR(64) NOT NULL,
user_id NVARCHAR(128) NOT NULL,
ip NVARCHAR(64) NULL,
amount INT NOT NULL,
credited BIT NOT NULL DEFAULT 0,
payload NVARCHAR(MAX) NOT NULL,
created_at DATETIME2 NOT NULL
);
END
");
$hasNote = log_has_note($pdo, $logTable);
if (!$hasNote) {
try {
$pdo->exec("IF COL_LENGTH('".obj_name($logTable)."','note') IS NULL
BEGIN ALTER TABLE {$logTable} ADD note NVARCHAR(200) NULL END");
$hasNote = log_has_note($pdo, $logTable);
} catch (Throwable $e) { $hasNote = false; }
}
/* 24h duplicate guard */
$dup = $pdo->prepare("
SELECT TOP 1 id FROM {$logTable}
WHERE server_id=:sid AND event=:ev AND account_id=:acc
AND created_at >= DATEADD(hour,-24,SYSUTCDATETIME())
");
$dup->execute([':sid'=>$server_id, ':ev'=>$event, ':acc'=>$accountId]);
if ($dup->fetch()) {
$sql = $hasNote
? "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at,note)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME(),:note)"
: "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME())";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':sid'=>$server_id, ':ev'=>$event, ':acc'=>$accountId, ':uid'=>$userId,
':ip'=>$reqIp, ':payload'=>$raw, ':note'=>'duplicate_24h'
]);
out(200, ['ok'=>true,'credited'=>false,'msg'=>'Already credited in last 24h']);
}
/* Check account */
$check = $pdo->prepare("SELECT TOP 1 {$accField} AS acc FROM {$cashTbl} WHERE {$accField}=:acc");
$check->execute([':acc'=>$accountId]);
if (!$check->fetch()) {
$sql = $hasNote
? "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at,note)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME(),:note)"
: "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME())";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':sid'=>$server_id, ':ev'=>$event, ':acc'=>$accountId, ':uid'=>$userId,
':ip'=>$reqIp, ':payload'=>$raw, ':note'=>'account_not_found'
]);
out(200, ['ok'=>true,'credited'=>false,'msg'=>'Account not found','accountId'=>$accountId]);
}
/* Credit or log */
$credited = false;
if ($amount > 0) {
$pdo->beginTransaction();
$upd = $pdo->prepare("
UPDATE {$cashTbl}
SET {$creditCol} = ISNULL({$creditCol},0) + :amt
WHERE {$accField} = :acc
");
$upd->execute([':amt'=>$amount, ':acc'=>$accountId]);
if ($upd->rowCount() < 1) {
$pdo->rollBack();
$sql = $hasNote
? "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at,note)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME(),:note)"
: "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME())";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':sid'=>$server_id, ':ev'=>$event, ':acc'=>$accountId, ':uid'=>$userId,
':ip'=>$reqIp, ':payload'=>$raw, ':note'=>'update_failed'
]);
out(500, ['ok'=>false,'error'=>'Update failed']);
}
$sql = $hasNote
? "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at,note)
VALUES(:sid,:ev,:acc,:uid,:ip,:amt,1,:payload,SYSUTCDATETIME(),NULL)"
: "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at)
VALUES(:sid,:ev,:acc,:uid,:ip,:amt,1,:payload,SYSUTCDATETIME())";
$log = $pdo->prepare($sql);
$log->execute([
':sid'=>$server_id, ':ev'=>$event, ':acc'=>$accountId, ':uid'=>$userId,
':ip'=>$reqIp, ':amt'=>$amount, ':payload'=>$raw,
]);
$pdo->commit();
$credited = true;
} else {
$sql = $hasNote
? "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at,note)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME(),:note)"
: "INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME())";
$log = $pdo->prepare($sql);
$log->execute([
':sid'=>$server_id, ':ev'=>$event, ':acc'=>$accountId, ':uid'=>$userId,
':ip'=>$reqIp, ':payload'=>$raw, ':note'=>'zero_reward'
]);
}
out(200, [
'ok'=>true,
'credited'=>$credited,
'amount'=>$credited ? $amount : 0,
'currency'=>$creditCol,
'accountId'=>$accountId,
]);
} catch (Throwable $e) {
try {
if (isset($pdo)) {
$stmt = $pdo->prepare("
INSERT INTO {$logTable}(server_id,event,account_id,user_id,ip,amount,credited,payload,created_at)
VALUES(:sid,:ev,:acc,:uid,:ip,0,0,:payload,SYSUTCDATETIME())
");
$stmt->execute([
':sid'=>$server_id ?? 0,
':ev'=>$event ?? '',
':acc'=>$accountId ?? '',
':uid'=>$userId ?? '',
':ip'=>$reqIp ?? '',
':payload'=>$raw ?? '',
]);
}
} catch(Throwable $ignored){}
out(500, ['ok'=>false,'error'=>'Server error','detail'=>$e->getMessage()]);
}
Return
{"credited": true} on success; otherwise {"credited": false}. The account test response will show the HTTP code and endpoint response body.6) Expected response and test result
- On success, return HTTP
200and a JSON response withok: trueandcredited: true. - If the account does not exist, return a successful response with
credited: falseso the owner can see the reason without retry loops. - If the HMAC signature is wrong, return HTTP
401. This usually means the secret in MUOGG does not matchconfig.php. - If the URL is blocked, private, invalid or unreachable, the test will show
bad_urlornetwork_error. - If the same account already received the same event recently, the example endpoint logs it as duplicate and does not credit again.
For the test, use a real MU account name that exists in the configured cash table. If the username is not found, the endpoint is working but no coins will be added.
7) Create the log table (T-SQL)
If you prefer to create it manually, run this in SQL Server:
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'MMOPlusPostbackLog')
BEGIN
CREATE TABLE [MuOnline].[dbo].[MMOPlusPostbackLog](
[id] BIGINT IDENTITY(1,1) PRIMARY KEY,
[server_id] INT NOT NULL,
[event] NVARCHAR(24) NOT NULL,
[account_id] NVARCHAR(64) NOT NULL,
[user_id] NVARCHAR(128) NOT NULL,
[ip] NVARCHAR(64) NULL,
[amount] INT NOT NULL,
[credited] BIT NOT NULL DEFAULT 0,
[payload] NVARCHAR(MAX) NOT NULL,
[created_at] DATETIME2 NOT NULL
);
END