MMOLove Docs
Referral Kit

Signing & security

The exact X-MMOLove-Signature HMAC scheme — sign over the raw body, the header format, the replay window, idempotency, and secret rotation.

Every inbound referral event is authenticated with an HMAC-SHA256 signature keyed by your per-server secret. This page is the authoritative reference for that scheme; if a snippet ever disagrees with this page, this page wins.

The one rule that matters most: sign the raw body

The #1 integration bug: signing a re-serialized object instead of the exact bytes you send. Build the JSON body string once, compute the MAC over that exact string, and send that same string as the request body. If you serialize twice (once to sign, once to send), whitespace or key order can differ and the MAC will — correctly — fail to verify with a 401.

The MMOLove endpoint reads the raw request body first (await req.text()) and verifies the signature over those exact bytes before it parses any JSON. So the contract on your side is symmetric: serialize once, sign those bytes, send those bytes.

What gets signed

The MAC is computed over the string "<t>.<rawBody>" — the timestamp, a literal dot (.), then the raw JSON body:

mac = HMAC_SHA256(secret, t + "." + rawBody)   // lowercase hex
  • secret — your per-server signing secret (minted when you enable referrals; rotatable any time).
  • t — the Unix-seconds timestamp you put in the header (see below). It is part of the signed string, so it can't be tampered with independently.
  • rawBody — the exact request-body bytes.

The header format

Send the signature in the X-MMOLove-Signature header:

X-MMOLove-Signature: t=<unix>,v1=sha256=<hex>[,kid=<key-id>]
FieldRequiredMeaning
tyesUnix seconds at signing time. Drives the replay window and is part of the signed string. Must be a positive integer.
v1yessha256=<hex> where <hex> is the lowercase-hex HMAC_SHA256(secret, "<t>.<rawBody>"). The sha256= scheme prefix is required.
kidnoOptional key id for rotation. Carried through; mapping kid → secret is yours to manage.

The parser is tolerant of field order and surrounding whitespace, and ignores unknown fields — but t and v1 must both be present and well-formed, or the header is rejected as malformed (400).

Verification order (and why)

When your request arrives, MMOLove:

  1. Parses the header. Missing/malformed t or v1400.
  2. Recompiles the MAC over "<t>.<rawBody>" and compares it to your v1 using a constant-time (timing-safe) comparison. Mismatch → 401 (bad_signature).
  3. Checks the replay window on t after the MAC verifies. Outside the window → 401 (stale).

The MAC is verified before the clock check on purpose: a forged timestamp can't be used to probe the replay window, because it would change the signed string and fail the MAC first.

Replay window

The signature timestamp t must be within ±5 minutes (300 seconds) of MMOLove server time. Anything older or further in the future is rejected as stale (401).

Implications:

  • Keep your server clock in sync (NTP). A drifting clock will silently fail signatures once it exceeds ±5 min.
  • Sign at send time — don't pre-compute t and queue the request for minutes.
  • On retry, re-sign with a fresh t (a stale t from the first attempt will be rejected). Keep the same server_event_id so the retry is still deduped.

Idempotency & dedup

Signing keeps requests authentic; idempotency keeps retries safe.

  • Every event carries a server_event_id — your idempotency key for that event.
  • Dedup is on the tuple (token, type, server_event_id) — never on token alone — so registered and qualified can both land for one referral.
  • The event row is inserted before any state processing, so a duplicate is absorbed at the gate and returns 200 (never a 4xx), with { "ok": true, "duplicate": true }.

So: retry freely. Reuse the same server_event_id for the same logical event and a replay is a guaranteed no-op. Use a new server_event_id for each distinct event (e.g. reg-<player> for registration, qual-<player> for qualification).

Per-server secret + rotation

Each server has its own signing secret. The endpoint looks the secret up by server_id, so one compromised server can never spoof another's conversions — secrets are isolated.

Rotation: on the Referrals tab, Rotate secret mints a new secret and shows it once. Rotating invalidates the old secret immediately, so:

  1. Rotate in the dashboard and copy the new secret.
  2. Update your server's stored secret (env var / secrets manager).
  3. Resume signing with the new secret.

The kid field exists for callers who want to coordinate a zero-downtime cutover (tag each request with the key id you signed with). MMOLove carries kid through; you own the kid → secret mapping on your side.

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

Correct signing snippet per language

Each snippet builds the body once, signs "<t>.<rawBody>", and sends the same bytes. These are the signing cores; the full request helpers live in the SDK guides.

<?php
// $raw is the exact JSON string you will POST as the body.
$t   = time();
$mac = hash_hmac('sha256', $t . '.' . $raw, $secret);   // lowercase hex
$sig = "t={$t},v1=sha256={$mac}";
// Header:  X-MMOLove-Signature: {$sig}

Note Convert.ToHexString is upper-case by default in .NET — lower-case it. The endpoint normalises the incoming hex, but lower-casing keeps your value identical to the other stacks.

On this page