MMOLove Docs
Reward Callbacks — guides

Signing & verification

The exact X-MMOLove-Signature HMAC scheme for reward callbacks — verify over the raw body, the header format, a worked example, and a replay-window recommendation.

Every reward callback is signed so you can prove it genuinely came from MMOLove before you grant anything. This page is the authoritative reference for that scheme; if a snippet ever disagrees with this page, this page wins.

What gets signed

The MAC is an HMAC-SHA256, keyed by your per-server secret, computed over the string "<t>.<rawBody>" — the timestamp, a literal dot (.), then the raw JSON body — emitted as lower-case hex:

v1 = HMAC_SHA256(secret, t + "." + rawBody)   // lower-case hex
  • secret — your per-server signing secret (mint / rotate it on the Integration tab; shown once).
  • t — the Unix-seconds timestamp from the header (it equals the body's timestamp). It's part of the signed string, so it can't be tampered with independently.
  • rawBody — the exact request-body bytes MMOLove sent.

The header format

The signature arrives in the X-MMOLove-Signature header:

X-MMOLove-Signature: t=<unix>,v1=<hex>
FieldMeaning
tUnix seconds at delivery (equals the body's timestamp).
v1HMAC_SHA256(secret, "<t>.<rawBody>") in lower-case hex. Bare hex — there is no sha256= prefix.

MMOLove also sends an X-MMOLove-Event header (heart.counted / heart.test) so you can branch without parsing the body.

The reward-callback v1 is bare hex (v1=<hex>). The Referral Kit's v1 carries a sha256= prefix (v1=sha256=<hex>). Same MAC, different encoding — don't copy a Referral-Kit parser verbatim. See the comparison.

The one rule that matters most: verify the raw body

The #1 integration bug: verifying against a re-serialized body instead of the exact bytes you received. If you parse the JSON and then re-stringify it to compute the MAC, whitespace or key order can differ and the MAC will — correctly — fail.

Fix: capture the raw request body first (before any JSON parsing), compute the MAC over those exact bytes, then parse.

Most frameworks expose the raw body, but you sometimes have to ask for it:

  • PHPfile_get_contents('php://input').
  • Expressexpress.raw() (or req.rawBody) — not express.json(), which discards the original bytes.
  • Next.js Route Handlerawait req.text().
  • Flaskrequest.get_data() (bytes), not request.get_json().
  • ASP.NET — read Request.Body to a string before model binding.

Verification recipe

1. Parse X-MMOLove-Signature → t, v1.
2. expected = HMAC_SHA256(secret, t + "." + rawBody)  // lower-case hex
3. Compare expected to v1 with a CONSTANT-TIME comparison.
   mismatch → reject (do not reward).
4. (Recommended) Check |now - t| ≤ a few minutes → else reject as stale.
5. Reward.

Use a timing-safe comparison (hash_equals, crypto.timingSafeEqual, hmac.compare_digest, CryptographicOperations.FixedTimeEquals) — never == on the hex strings.

Worked example

For a secret of s3cr3t, a delivery at t=1733500000, and a raw body of:

{"event":"heart.counted","server_id":"srv_123","username":"PlayerOne","heart_id":"h_1","period":"2026-06","timestamp":1733500000,"streak_day":7}

you compute:

v1 = HMAC_SHA256("s3cr3t", "1733500000." + rawBody)   // lower-case hex
header = "t=1733500000,v1=" + v1

and your verifier recomputes the same v1 over the same raw bytes and compares. (Sign over the literal 1733500000.{…rawBody…} string — the dot is a separator, not part of either side.)

The reward callback does not require you to enforce a replay window, but you should: reject deliveries whose t is more than a few minutes from your clock, so a captured request can't be replayed against you later.

  • A ±5-minute window matches the Referral Kit's tolerance and is a sensible default.
  • Keep your server clock in NTP-sync, or a drifting clock will reject valid callbacks.
  • Pair the window with heart_id idempotency: the window stops replays, the heart_id key stops double-rewards from legitimate retries. See Payload → idempotency.

Per-server secret + rotation

Each server has its own signing secret, looked up by server_id, so one server's secret can never sign for another. Rotate any time on the Integration tab:

  1. Click Rotate secret and copy the new value (shown once).
  2. Update your handler's stored secret (env var / secrets manager).
  3. Rotation invalidates the old secret immediately — callbacks signed with it will then fail your check, so update promptly.

The signing secret is server-side only. Never ship it to a browser, a game client, or a public repo. If it leaks, rotate it immediately.

Relationship to the Referral Kit

Both webhooks share the same HMAC coreHMAC_SHA256(secret, "<t>.<rawBody>"), lower-case hex, over the raw body, with t in the X-MMOLove-Signature header. The difference is the v1 encoding and the direction of trust:

Reward callback (this page)Referral Kit
Headert=<unix>,v1=<hex>t=<unix>,v1=sha256=<hex>[,kid=…]
v1 valuebare lower-case hexsha256= + lower-case hex
kid rotation hintnot sentoptional
MACHMAC_SHA256(secret, "<t>.<rawBody>")identical
Who verifiesyou (MMOLove → you)MMOLove (you → MMOLove)
Replay windowenforce on your side (recommended)±5 min, enforced by MMOLove

If you already verify Referral-Kit signatures, reuse the same MAC and just strip the sha256= prefix when reading a reward-callback v1.

See also

On this page