MMOLove Docs
Reward Callbacks — guidesHandler guides

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.

A complete Python handler. Shown for Flask and FastAPI — both read the raw body, verify the HMAC, branch on heart.test, and grant idempotently on heart_id.

The official mmolove-callback package is zero-dependency (stdlib only) and verifies in one call:

pip install mmolove-callback
from mmolove_callback import verify_heart_callback

@app.post("/mmolove/callback")
def mmolove():
    event = verify_heart_callback(
        request.get_data(),                            # RAW bytes, not request.get_json()
        request.headers.get("X-MMOLove-Signature"),
        os.environ["MMOLOVE_CALLBACK_SECRET"],
    )
    if event is None:
        return ("", 403)
    if event["event"] == "heart.test":
        return ("", 200)                               # acknowledge, don't pay out
    reward_player(event["username"], event.get("streak_day"))
    return ("", 200)

verify_heart_callback(raw_body, signature_header, secret, tolerance=300) returns the parsed event dict on success, else None (bad signature, stale t, or unparseable). raw_body accepts str or bytes. Older GET integration? Use verify_legacy_get(request.args, secret).

The rest of this page is the from-scratch path — a stdlib-only 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.
import os
SECRET = os.environ["MMOLOVE_CALLBACK_SECRET"]   # never hardcode in a public repo

Verify helper (shared)

import hashlib
import hmac
import time


def verify_callback(secret: str, raw: bytes, header: str | None) -> bool:
    """Verify a reward-callback request over the EXACT raw body bytes."""
    if not header:
        return False
    sig = {}
    for part in header.split(","):
        k, _, v = part.partition("=")
        sig[k.strip()] = v.strip()
    t = sig.get("t")
    v1 = sig.get("v1")          # bare hex — no "sha256=" prefix on reward callbacks
    if not t or not v1:
        return False

    # MAC over "<t>.<rawBody>". Build the signed bytes from the raw body exactly.
    signed = t.encode() + b"." + raw
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):   # constant-time
        return False

    # Recommended: reject stale deliveries (±5 min) to guard against replay.
    if abs(int(time.time()) - int(t)) > 300:
        return False

    return True

Flask handler

Use request.get_data() (raw bytes), not request.get_json(), to capture the body for verification. Parsing first and re-serializing changes the bytes and the MAC will fail.

import json
from flask import Flask, request

app = Flask(__name__)


@app.post("/mmolove/callback")
def mmolove_callback():
    raw = request.get_data()                       # RAW bytes — read first
    header = request.headers.get("X-MMOLove-Signature")

    if not verify_callback(SECRET, raw, header):
        return "", 401                             # bad signature → MMOLove retries

    body = json.loads(raw)
    if body.get("event") == "heart.test":
        return "", 200                             # acknowledge, don't pay out

    username = body.get("username")
    heart_id = body.get("heart_id")
    streak_day = body.get("streak_day")            # may be ABSENT (None)
    if not username or not heart_id:
        return "", 400

    if already_rewarded(heart_id):
        return "", 200                             # safe replay of a retry
    grant_reward(username, streak_day)
    mark_rewarded(heart_id)
    return "", 200                                 # delivered

FastAPI handler

import json
from fastapi import FastAPI, Request, Response

app = FastAPI()


@app.post("/mmolove/callback")
async def mmolove_callback(request: Request):
    raw = await request.body()                     # RAW bytes
    header = request.headers.get("X-MMOLove-Signature")

    if not verify_callback(SECRET, raw, header):
        return Response(status_code=401)

    body = json.loads(raw)
    if body.get("event") == "heart.test":
        return Response(status_code=200)

    username = body.get("username")
    heart_id = body.get("heart_id")
    streak_day = body.get("streak_day")
    if not username or not heart_id:
        return Response(status_code=400)

    if already_rewarded(heart_id):
        return Response(status_code=200)
    grant_reward(username, streak_day)
    mark_rewarded(heart_id)
    return Response(status_code=200)

Grant + idempotency

def already_rewarded(heart_id: str) -> bool:
    # SELECT 1 FROM mmolove_rewards WHERE heart_id = %s
    return False


def mark_rewarded(heart_id: str) -> None:
    # INSERT INTO mmolove_rewards (heart_id) VALUES (%s) ON CONFLICT DO NOTHING
    ...


def grant_reward(username: str, streak_day: int | None) -> None:
    reward = 100                                    # base
    if streak_day is not None:
        if streak_day % 30 == 0:
            reward += 1000
        elif streak_day % 7 == 0:
            reward += 200
    # UPDATE accounts SET points = points + %s WHERE username = %s

streak_day may be absentbody.get("streak_day") returns None. Treat None 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 rejected it: confirm you verified request.get_data() / await request.body() (the raw bytes), not a re-encoded dict. See Testing & self-verify.

Next

On this page