.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.
A complete ASP.NET Core Minimal API handler. It reads the raw body, verifies the
HMAC, branches on heart.test, and grants idempotently on heart_id.
Use the package (recommended)
The official MMOLove.Callback package (net8.0, zero NuGet deps)
verifies in one call:
dotnet add package MMOLove.Callbackusing MMOLove.Callback;
app.MapPost("/mmolove/callback", async (HttpRequest req) =>
{
using var reader = new StreamReader(req.Body);
string rawBody = await reader.ReadToEndAsync(); // RAW body, not model-bound
string? header = req.Headers["X-MMOLove-Signature"];
var result = MmoLoveCallback.VerifyHeartCallback(
rawBody, header, builder.Configuration["MMOLove:CallbackSecret"]!);
if (!result.Valid) return Results.StatusCode(403);
if (result.Event!.Event == "heart.test") return Results.Ok(); // ack, don't pay out
RewardPlayer(result.Event.Username!);
return Results.Ok();
});VerifyHeartCallback(rawBody, signatureHeader, secret, toleranceSec = 300) returns
a VerifyResult with .Valid and, on success, the parsed .Event (Event,
ServerId, Username, HeartId, Period, Timestamp). Older GET integration?
Use MmoLoveCallback.VerifyLegacyGet(username, ts, sig, secret).
streak_day rides along in the raw payload but isn't on the SDK's HeartEvent —
deserialize the raw body to your own record (see the from-scratch handler below,
which has int? StreakDay) if you scale rewards by streak.
The rest of this page is the from-scratch path — a single 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 (config / secrets — never hardcode in a public repo).
- A public
https://endpoint set as your callback URL.
var SECRET = builder.Configuration["MMOLove:CallbackSecret"]!; // from config/envVerify helper
using System.Security.Cryptography;
using System.Text;
static class MmoLove
{
/// <summary>Verify a reward-callback request over the EXACT raw body string.</summary>
public static bool VerifyCallback(string secret, string raw, string? header)
{
if (string.IsNullOrEmpty(header)) return false;
string? t = null, v1 = null; // v1 is BARE hex — no "sha256=" prefix
foreach (var part in header.Split(','))
{
var i = part.IndexOf('=');
if (i < 0) continue;
var k = part[..i].Trim();
var val = part[(i + 1)..].Trim();
if (k == "t") t = val;
else if (k == "v1") v1 = val;
}
if (t is null || v1 is null) return false;
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert
.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{raw}")))
.ToLowerInvariant();
// Constant-time compare.
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1)))
return false;
// Recommended: reject stale deliveries (±5 min) to guard against replay.
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - long.Parse(t)) > 300) return false;
return true;
}
}Convert.ToHexString is upper-case by default — .ToLowerInvariant() it so it
matches MMOLove's lower-case hex. (The compare is byte-for-byte, so the case must
agree.)
The endpoint
Read the request body to a string yourself before any JSON model binding, and verify over that exact string. Letting the framework deserialize first loses the original bytes and the MAC will fail.
using System.Text.Json;
app.MapPost("/mmolove/callback", async (HttpRequest req) =>
{
// RAW body — read before any JSON parsing.
using var reader = new StreamReader(req.Body, Encoding.UTF8);
var raw = await reader.ReadToEndAsync();
var header = req.Headers["X-MMOLove-Signature"].ToString();
if (!MmoLove.VerifyCallback(SECRET, raw, header))
return Results.Unauthorized(); // 401 → MMOLove retries
var body = JsonSerializer.Deserialize<CallbackBody>(raw);
if (body is null) return Results.BadRequest();
if (body.Event == "heart.test")
return Results.Ok(); // acknowledge, don't pay out
if (string.IsNullOrEmpty(body.Username) || string.IsNullOrEmpty(body.HeartId))
return Results.BadRequest();
if (await AlreadyRewarded(body.HeartId))
return Results.Ok(); // safe replay of a retry
await GrantReward(body.Username, body.StreakDay); // StreakDay may be null
await MarkRewarded(body.HeartId);
return Results.Ok(); // delivered
});
// streak_day is OPTIONAL — make it nullable so an absent field maps to null.
record CallbackBody(
[property: JsonPropertyName("event")] string Event,
[property: JsonPropertyName("username")] string Username,
[property: JsonPropertyName("heart_id")] string HeartId,
[property: JsonPropertyName("period")] string Period,
[property: JsonPropertyName("timestamp")] long Timestamp,
[property: JsonPropertyName("streak_day")] int? StreakDay);Grant + idempotency
// Key idempotency on heart_id so a retried delivery is a no-op.
static Task<bool> AlreadyRewarded(string heartId) => Task.FromResult(false);
static Task MarkRewarded(string heartId) => Task.CompletedTask;
static Task GrantReward(string username, int? streakDay)
{
var reward = 100; // base
if (streakDay is int s)
{
if (s % 30 == 0) reward += 1000;
else if (s % 7 == 0) reward += 200;
}
// UPDATE accounts SET points = points + reward WHERE username = username
return Task.CompletedTask;
}streak_day may be absent — modelled as int? StreakDay. Treat null 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. A 401 means your signature check rejected it: confirm you
verified the raw body string (read from req.Body), not a re-serialized object, and
that your hex is lower-case. See
Testing & self-verify.
Next
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.
cURL
Verify a reward callback with no framework — recompute the HMAC over a captured raw body with openssl, plus a CGI-style shell handler.