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
| Code | Meaning | Typical cause |
|---|---|---|
200 | OK / idempotent no-op | Event accepted, or a duplicate / first-touch conflict (still 200). |
400 | Malformed request | Bad header, unparseable body, missing/invalid field. |
401 | Signature rejected | Bad MAC or stale timestamp (replay window). |
404 | Unknown target | Unknown server, referrals not enabled, no secret, or unknown token. |
422 | Invalid state transition | Event illegal from the referral's current state. |
500 | Internal error | Server-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:
| Body | What happened | Action |
|---|---|---|
{ "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:
| Cause | Error body | Fix |
|---|---|---|
| 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:
| Cause | Error body | Fix |
|---|---|---|
| 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
| Cause | Error body | Fix |
|---|---|---|
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:
- Did the event arrive at all? No row → it never reached us (check the URL,
network, and that you got a
2xx). - Is the state advancing? A
registeredrow but the state stuck atclicked/registeredafter you sentqualified→ thequalifieddidn't land (or422'd). - Are you double-sending? Multiple rows for the same logical event → vary
your
server_event_idper 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_idfor both events → ✅reg-…andqual-…. - ❌ Sending
qualifiedfirst → ✅registeredmints the anchor first. - ❌ Using a renamable identity → ✅ a stable account id (first-touch keys off it).
- ❌ Retrying a first-touch
200forever → ✅ treatignoredas 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
Events reference
Per-event payload schemas for the referral ingest endpoint — registered and qualified — with field tables, responses, and server_event_id idempotency.
Testing & sandbox
Dry-run referral events with test:true, use the dashboard "Send test event" button, and read the delivery log to confirm your wiring.