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 hexsecret— 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'stimestamp). 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>| Field | Meaning |
|---|---|
t | Unix seconds at delivery (equals the body's timestamp). |
v1 | HMAC_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:
- PHP —
file_get_contents('php://input'). - Express —
express.raw()(orreq.rawBody) — notexpress.json(), which discards the original bytes. - Next.js Route Handler —
await req.text(). - Flask —
request.get_data()(bytes), notrequest.get_json(). - ASP.NET — read
Request.Bodyto 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=" + v1and 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.)
Replay window (recommended)
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_ididempotency: the window stops replays, theheart_idkey 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:
- Click Rotate secret and copy the new value (shown once).
- Update your handler's stored secret (env var / secrets manager).
- 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 core — HMAC_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 | |
|---|---|---|
| Header | t=<unix>,v1=<hex> | t=<unix>,v1=sha256=<hex>[,kid=…] |
v1 value | bare lower-case hex | sha256= + lower-case hex |
kid rotation hint | not sent | optional |
| MAC | HMAC_SHA256(secret, "<t>.<rawBody>") | identical |
| Who verifies | you (MMOLove → you) | MMOLove (you → MMOLove) |
| Replay window | enforce 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.