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.
Use the drop-in (recommended)
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 tabThe 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: deliveredGrant + 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 return | MMOLove does | When |
|---|---|---|
200 | Delivered — stop | Verified + accepted (incl. heart.test). |
401 | Retry | Signature failed (raw-body bug, wrong/old secret, stale t). |
400 | Retry | Body unparseable / missing username/heart_id. |
5xx | Retry | Your 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
Errors, retries & delivery
How MMOLove delivers reward callbacks — the retry schedule, delivery statuses, what counts as success, and how to troubleshoot failures.
Node / JS
A complete, copy-paste Node reward-callback handler — capture the raw body, verify X-MMOLove-Signature with node:crypto, reward the voter, stay idempotent.