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.
Use the package (recommended)
The official mmolove-callback package is zero-dependency (stdlib
only) and verifies in one call:
pip install mmolove-callbackfrom 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 repoVerify 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 TrueFlask 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 # deliveredFastAPI 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 = %sstreak_day may be absent — body.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
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.
.NET / C#
A complete, copy-paste .NET reward-callback handler — read the raw body, verify X-MMOLove-Signature with HMACSHA256, reward the voter, stay idempotent.