MMOLove Docs
Referral Kit

Errors & troubleshooting

Every status code the ingest endpoint returns, with cause and fix — plus the raw-body-signing gotcha and how to debug with the delivery log.

The ingest endpoint returns a small, well-defined set of status codes. This page lists every one with its cause and the fix, then walks through debugging.

Status codes at a glance

CodeMeaningTypical cause
200OK / idempotent no-opEvent accepted, or a duplicate / first-touch conflict (still 200).
400Malformed requestBad header, unparseable body, missing/invalid field.
401Signature rejectedBad MAC or stale timestamp (replay window).
404Unknown targetUnknown server, referrals not enabled, no secret, or unknown token.
422Invalid state transitionEvent illegal from the referral's current state.
500Internal errorServer-side failure — retry with backoff.

A 200 always carries ok: true. Inspect the rest of the body to know which kind of success it was.

200 — and the "ignored" successes

Not every 200 advanced state. The body tells you which case you hit:

BodyWhat happenedAction
{ "ok": true, "referral_id": "...", "state": "registered"|"qualified" }Advanced (or idempotent no-op into the same state).Done. Grant the reward on qualified.
{ "ok": true, "duplicate": true }Exact (token, type, server_event_id) already arrived.None — safe replay.
{ "ok": true, "ignored": "first_touch_conflict" }A different referrer already registered this referee. First-touch won; this is a no-op.Stop retrying. This is expected when two referrers race for one referee.
{ "ok": true, "test": true }A test: true payload was signature-verified but not written.Wiring confirmed — send a real event.

The first-touch conflict returns 200 (not a 4xx) on purpose — so your kit stops retrying. The event is a harmless no-op against the existing anchor.

400 — malformed

The request didn't pass field validation. Causes and fixes:

CauseError bodyFix
Body isn't valid JSON{ "error": "body is not valid JSON" }Send a parseable JSON object.
Couldn't read the body at all{ "error": "could not read body" }Check your client is actually sending a body.
Missing/invalid event{ "error": "event must be one of registered|qualified|reversed" }Use exactly one of those three values.
Missing token{ "error": "token is required" }Include the captured mmref token.
Missing server_id{ "error": "server_id is required" }Include your server id.
Missing server_event_id{ "error": "server_event_id is required" }Include a unique idempotency key.
registered without referee_identity{ "error": "referee_identity is required for a registered event" }Supply the referee's stable identity.
Malformed signature header{ "error": "missing or malformed X-MMOLove-Signature header" }Fix the header format (see Signing).

Remember: empty strings count as missing (fields are trimmed).

401 — signature rejected

The header was well-formed but verification failed. The body distinguishes the two cases:

CauseError bodyFix
MAC mismatch{ "error": "signature rejected: bad_signature" }You almost certainly signed different bytes than you sent — see the gotcha below. Also check you're using the current secret.
Stale timestamp{ "error": "signature rejected: stale" }t is outside the ±5-min window. Sync your clock (NTP); sign at send time; re-sign on retry.

The #1 integration bug: signing a re-serialized body

A bad_signature is overwhelmingly caused by signing one JSON string and sending a slightly different one. Re-serializing changes whitespace or key order, and the MAC — correctly — won't match.

Fix: build the body string once, sign those exact bytes, send those exact bytes. Never serialize a second time after signing.

Other bad_signature culprits:

  • Wrong secret — you rotated in the dashboard but your server is still using the old one (rotation invalidates the old secret immediately).
  • Signed the wrong string — the MAC is over "<t>.<rawBody>" (timestamp, a literal dot, then the raw body), not the body alone.
  • Encoding mismatch — sign UTF-8 bytes; emit lower-case hex.

404 — unknown target

CauseError bodyFix
No config row for server_id{ "error": "unknown server" }Check the server_id matches your server on the Referrals tab.
Referrals not enabled / no secret{ "error": "referrals not enabled for this server" }Enable referrals (which mints the secret) on the Referrals tab.
Token has no click row for this server{ "error": "unknown referral token for this server" }The mmref you captured doesn't match a tracked click for this server. Make sure you're sending the token exactly as captured, and that it came from this server's /r link.

422 — invalid state transition

The event is illegal from the referral's current state. The body tells you where it was:

{ "error": "invalid state transition", "from": "clicked", "event": "qualified" }

The common case is qualified before registered (from: "clicked") — the anchor must be minted by registered first. Send the registration, then qualify.

See Concepts → legal transitions for the full table.

500 — internal error

A server-side failure ({ "error": "internal error" }). It's safe to retry with exponential backoff — your server_event_id makes the retry idempotent, so you can't double-count. If it persists, the wiring is fine; reach out.

Debug with the delivery log

The fastest way to see what's actually happening is the Delivery log on your server's Referrals tab. Every inbound event is appended there (it's the system of record), newest first, with:

  • the event type (registered / qualified / reversed),
  • the referral's current lifecycle state,
  • the received-at time, and
  • a short payload snippet.

Use it to confirm:

  1. Did the event arrive at all? No row → it never reached us (check the URL, network, and that you got a 2xx).
  2. Is the state advancing? A registered row but the state stuck at clicked/registered after you sent qualified → the qualified didn't land (or 422'd).
  3. Are you double-sending? Multiple rows for the same logical event → vary your server_event_id per distinct event (and reuse it on retries).

Common mistakes checklist

  • ❌ Signing a re-serialized body → ✅ sign the exact bytes you send.
  • ❌ Signing the body alone → ✅ sign "<t>.<rawBody>".
  • ❌ Reusing one server_event_id for both events → ✅ reg-… and qual-….
  • ❌ Sending qualified first → ✅ registered mints the anchor first.
  • ❌ Using a renamable identity → ✅ a stable account id (first-touch keys off it).
  • ❌ Retrying a first-touch 200 forever → ✅ treat ignored as terminal.
  • ❌ Stale clock → ✅ NTP; sign at send time; re-sign (fresh t) on retry.
  • ❌ Old secret after a rotate → ✅ update your stored secret immediately.

See also

On this page