MMOLove Docs
Reward Callbacks — guidesHandler guides

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.

The official @mmolove/callback verifies the signature in one call:

npm i @mmolove/callback
import 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 absentstreakDay != 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

On this page