MMOLove Docs
Reward Callbacks — guidesHandler guides

PHP

A complete, copy-paste PHP reward-callback handler — read the raw body, verify the X-MMOLove-Signature, reward the voter, and stay idempotent on heart_id.

This is the complete PHP handler — one self-contained file you can drop into your server's web root. It reads the raw body, verifies the HMAC signature, branches on heart.test, and grants the reward idempotently.

PHP is the first-class path because it matches the classic vote-callback shape most server panels already run. If you've wired a vote callback before, this will feel identical — the difference is the signed JSON body.

The official mmolove-callback.php is a single-file drop-in — no Composer needed. Vendor it, require it, and verify in one call:

require 'mmolove-callback.php';

$event = mmolove_verify(getenv('MMOLOVE_CALLBACK_SECRET'));   // false on failure
if ($event === false) {
    http_response_code(403);
    exit;
}
if (($event['event'] ?? '') === 'heart.test') {
    http_response_code(200);   // acknowledge the test, don't pay out
    exit;
}

mmolove_grant_reward($event['username'], $event['streak_day'] ?? null);
http_response_code(200);

mmolove_verify($secret) reads php://input + the X-MMOLove-Signature header, verifies the HMAC + ±300 s window, and returns the decoded event array (or false). Using the legacy GET shim? mmolove_verify_legacy_get($secret) returns the username.

The rest of this page is the from-scratch version of that same file, if you'd rather paste it inline and tweak it.

What you need

  • Your signing secret from the Integration tab (store it in env / a secrets file — never hardcode in a public repo).
  • A public https:// URL pointing at this script (set it as the callback URL).
<?php
// Load from env / secrets manager — NEVER hardcode in a public repo.
$MMOLOVE_SECRET = getenv('MMOLOVE_CALLBACK_SECRET');   // from the Integration tab

The drop-in handler

mmolove-callback.php — the whole thing. Read the raw body first, sign those exact bytes, then act.

<?php
/**
 * mmolove-callback.php — reward-callback handler for MMOLove vote rewards.
 *
 * Flow: read raw body → verify HMAC → branch on heart.test → reward (idempotent
 * on heart_id) → 200. Any non-2xx tells MMOLove to retry.
 */

$MMOLOVE_SECRET = getenv('MMOLOVE_CALLBACK_SECRET');

// 1) Read the RAW body BEFORE any json_decode — we MUST sign the exact bytes.
$raw = file_get_contents('php://input');

// 2) Parse the X-MMOLove-Signature header:  t=<unix>,v1=<hex>
$header = $_SERVER['HTTP_X_MMOLOVE_SIGNATURE'] ?? '';
$sig = [];
foreach (explode(',', $header) as $part) {
    [$k, $v] = array_pad(explode('=', $part, 2), 2, '');
    $sig[trim($k)] = trim($v);
}
$t  = $sig['t']  ?? '';
$v1 = $sig['v1'] ?? '';   // NOTE: bare hex — no "sha256=" prefix on reward callbacks

// 3) Recompute the MAC over "<t>.<rawBody>" and compare constant-time.
$expected = hash_hmac('sha256', $t . '.' . $raw, $MMOLOVE_SECRET);   // lower-case hex
if ($t === '' || $v1 === '' || !hash_equals($expected, $v1)) {
    http_response_code(401);   // bad signature — MMOLove will retry
    exit;
}

// 4) (Recommended) reject stale deliveries — guards against replay.
if (abs(time() - (int) $t) > 300) {     // ±5 minutes
    http_response_code(401);
    exit;
}

// 5) Signature is good — now it's safe to parse the JSON.
$body = json_decode($raw, true);
if (!is_array($body)) {
    http_response_code(400);
    exit;
}

// 6) A test callback verifies the same way — acknowledge, but don't pay out.
if (($body['event'] ?? '') === 'heart.test') {
    http_response_code(200);
    exit;
}

// 7) Real vote — reward the voter, idempotently on heart_id.
$username  = (string) ($body['username'] ?? '');
$heartId   = (string) ($body['heart_id'] ?? '');
$streakDay = $body['streak_day'] ?? null;   // may be ABSENT — that's fine

if ($username === '' || $heartId === '') {
    http_response_code(400);
    exit;
}

if (mmolove_already_rewarded($heartId)) {
    http_response_code(200);   // safe replay of a retried delivery
    exit;
}

mmolove_grant_reward($username, $streakDay);   // YOUR in-game grant
mmolove_mark_rewarded($heartId);

http_response_code(200);   // tell MMOLove: delivered

Grant + idempotency helpers

Wire these to your game DB. The point is: a retried delivery (same heart_id) must be a no-op.

<?php
/** Have we already rewarded this exact vote? Key on heart_id. */
function mmolove_already_rewarded(string $heartId): bool
{
    // SELECT 1 FROM mmolove_rewards WHERE heart_id = :id
    // return (bool) $row;
    return false;
}

/** Record that this vote has been rewarded (so retries no-op). */
function mmolove_mark_rewarded(string $heartId): void
{
    // INSERT INTO mmolove_rewards (heart_id, rewarded_at) VALUES (:id, NOW())
    //   ON CONFLICT (heart_id) DO NOTHING
}

/** Hand out the loot. Scale it with the streak if present. */
function mmolove_grant_reward(string $username, ?int $streakDay): void
{
    $reward = 100;                       // base reward
    if ($streakDay !== null) {
        if ($streakDay % 30 === 0)      $reward += 1000;  // monthly bonus
        elseif ($streakDay % 7 === 0)   $reward += 200;   // weekly bonus
    }
    // UPDATE accounts SET points = points + :reward WHERE username = :username
}

streak_day may be absent$body['streak_day'] ?? null handles that. Treat null as "no streak info" and still grant the base reward; never withhold a reward just because the field is missing.

Test it

On the Integration tab, click Send test callback. You should see Test callback delivered (HTTP 2xx) and your handler should hit the heart.test branch (acknowledged, nothing paid out). If you get a 4xx, your signature check is rejecting the request — re-check that you signed php://input (the raw bytes), not a re-encoded array. See Testing & self-verify.

You returnMMOLove doesWhen
200Delivered — stopVerified + accepted (incl. heart.test).
401RetrySignature failed (raw-body bug, wrong/old secret, stale t).
400RetryBody unparseable / missing username/heart_id.
5xxRetryYour code threw — fix and the retry will land.

Keep $MMOLOVE_SECRET server-side. Never expose it to a browser or game client, and never commit it. If it leaks, Rotate secret on the Integration tab (which invalidates the old one immediately).

Next

On this page