MMOLove Docs
Reward Callbacks — guidesHandler guides

.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.

The official MMOLove.Callback package (net8.0, zero NuGet deps) verifies in one call:

dotnet add package MMOLove.Callback
using 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/env

Verify 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

On this page