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.
A complete Node handler. Shown for Express (the raw-body trap is explicit) and
a Next.js Route Handler — both read the raw body, verify the HMAC, branch on
heart.test, and grant idempotently.
Use the package (recommended)
The official @mmolove/callback verifies the signature in one call:
npm i @mmolove/callbackimport express from "express";
import { verifyHeartCallback } from "@mmolove/callback";
app.post("/mmolove/callback", express.raw({ type: "*/*" }), (req, res) => {
const { valid, event } = verifyHeartCallback({
rawBody: req.body.toString("utf8"), // RAW bytes — never parsed JSON
signatureHeader: req.header("X-MMOLove-Signature"),
secret: process.env.MMOLOVE_CALLBACK_SECRET,
});
if (!valid) return res.sendStatus(403);
if (event.event === "heart.test") return res.sendStatus(200); // ack, don't pay out
rewardPlayer(event.username); // once per day per player
res.sendStatus(200);
});verifyHeartCallback({ rawBody, signatureHeader, secret, toleranceSec? }) returns
{ valid, event? } — valid is false on a bad signature, a stale t (default
±300 s), or an unparseable body, and the parsed event is returned on success.
Pass the raw body bytes, not parsed JSON, or the signature won't match.
streak_day rides along in the raw payload but isn't on the SDK's typed
HeartEvent — read it via JSON.parse(rawBody).streak_day (treat a missing value
as "no streak info") if you scale rewards by streak. The from-scratch handler below
reads it directly.
The rest of this page is the from-scratch path — a dependency-free verify helper if you'd rather not add the package. It's exactly what the SDK wraps.
What you need
- Your signing secret from the Integration tab (env / secrets manager).
- A public
https://endpoint set as your callback URL.
// Load from env — never hardcode in a public repo.
const SECRET = process.env.MMOLOVE_CALLBACK_SECRET;Verify helper (shared)
import crypto from "node:crypto";
/**
* Verify a reward-callback request.
* @param {string} secret your per-server secret
* @param {string} raw the EXACT request-body string
* @param {string} header the X-MMOLove-Signature header value
* @returns {boolean}
*/
export function verifyCallback(secret, raw, header) {
if (!header) return false;
const sig = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = sig.t;
const v1 = sig.v1; // bare hex — no "sha256=" prefix on reward callbacks
if (!t || !v1) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${raw}`)
.digest("hex");
// Constant-time compare (equal-length buffers required).
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
// Recommended: reject stale deliveries (±5 min) to guard against replay.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;
return true;
}Express handler
Mount express.raw() (not express.json()) for the callback route. express.json()
parses and discards the original bytes, so you'd verify against a re-serialized
body and the MAC would fail.
import express from "express";
import { verifyCallback } from "./verify-callback.js";
const SECRET = process.env.MMOLOVE_CALLBACK_SECRET;
const app = express();
// Raw body ONLY for the callback path.
app.post(
"/mmolove/callback",
express.raw({ type: "application/json" }),
async (req, res) => {
const raw = req.body.toString("utf8"); // exact bytes
const header = req.get("X-MMOLove-Signature");
if (!verifyCallback(SECRET, raw, header)) {
return res.status(401).end(); // bad signature → MMOLove retries
}
let body;
try {
body = JSON.parse(raw);
} catch {
return res.status(400).end();
}
if (body.event === "heart.test") {
return res.status(200).end(); // acknowledge, don't pay out
}
const { username, heart_id: heartId, streak_day: streakDay } = body;
if (!username || !heartId) return res.status(400).end();
if (await alreadyRewarded(heartId)) return res.status(200).end(); // safe replay
await grantReward(username, streakDay); // streakDay may be undefined
await markRewarded(heartId);
res.status(200).end(); // delivered
},
);Next.js Route Handler
// app/mmolove/callback/route.ts
import { verifyCallback } from "@/lib/verify-callback";
const SECRET = process.env.MMOLOVE_CALLBACK_SECRET!;
export async function POST(req: Request): Promise<Response> {
const raw = await req.text(); // RAW body — read before JSON.parse
const header = req.headers.get("X-MMOLove-Signature");
if (!verifyCallback(SECRET, raw, header)) {
return new Response(null, { status: 401 });
}
const body = JSON.parse(raw);
if (body.event === "heart.test") return new Response(null, { status: 200 });
const { username, heart_id: heartId, streak_day: streakDay } = body;
if (!username || !heartId) return new Response(null, { status: 400 });
if (await alreadyRewarded(heartId)) return new Response(null, { status: 200 });
await grantReward(username, streakDay);
await markRewarded(heartId);
return new Response(null, { status: 200 });
}Grant + idempotency
// Key idempotency on heart_id so a retried delivery is a no-op.
async function alreadyRewarded(heartId) {
// SELECT 1 FROM mmolove_rewards WHERE heart_id = $1
return false;
}
async function markRewarded(heartId) {
// INSERT INTO mmolove_rewards (heart_id) VALUES ($1) ON CONFLICT DO NOTHING
}
async function grantReward(username, streakDay) {
let reward = 100; // base
if (streakDay != null) {
if (streakDay % 30 === 0) reward += 1000;
else if (streakDay % 7 === 0) reward += 200;
}
// UPDATE accounts SET points = points + reward WHERE username = username
}streak_day may be absent — streakDay != null handles undefined. Treat it
as "no streak info" and still grant the base reward.
Test it
Click Send test callback on the Integration tab — expect a 2xx and the
heart.test branch (acknowledged, nothing paid out). A 401 means your signature
check is rejecting it: confirm you verified the raw body (express.raw() /
req.text()), not a re-parsed object. See
Testing & self-verify.
Next
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.
Python
A complete, copy-paste Python reward-callback handler — read the raw body, verify X-MMOLove-Signature with hmac + hashlib, reward the voter, stay idempotent.