{
  "openapi": "3.1.0",
  "info": {
    "title": "MMOLove Referral API",
    "version": "1.0.0",
    "summary": "Server-to-server referral event ingest + the heart-reward callback webhook.",
    "description": "The public ingest endpoint for the MMOLove **Referral Kit**. Your game server's backend reports referral lifecycle events (`registered`, `qualified`, `reversed`) as your referred players progress, each signed with your per-server HMAC secret.\n\n**MMOLove handles attribution; you own the in-game economy.** We track the click → register → qualify journey and credit the referrer; you decide what \"qualified\" means and grant the reward.\n\nThis document also models the inbound **reward callback** (`heart.counted`) under `webhooks` — the webhook MMOLove POSTs to *your* server when a player hearts your listing. Note the two signature header forms differ only in the `v1` encoding (referral uses `v1=sha256=<hex>`; the callback uses a bare `v1=<hex>`).\n\nInstallable SDKs wrap this contract in every major stack — see https://mmolove.com/docs/sdks.",
    "contact": {
      "name": "MMOLove Developer Platform",
      "url": "https://mmolove.com/docs"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://mmolove.com/terms"
    }
  },
  "servers": [
    {
      "url": "https://mmolove.com",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Referral events",
      "description": "Outbound: your server POSTs signed lifecycle events to MMOLove."
    }
  ],
  "externalDocs": {
    "description": "Referral Kit guide (concepts, signing, per-language SDKs)",
    "url": "https://mmolove.com/docs/referral-kit"
  },
  "paths": {
    "/api/referral/events": {
      "post": {
        "operationId": "ingestReferralEvent",
        "tags": ["Referral events"],
        "summary": "Report a referral lifecycle event",
        "description": "Your server's backend POSTs a signed lifecycle event (`registered`, `qualified`, or `reversed`) as a referred player progresses.\n\n**Signing.** The request is authenticated with an HMAC-SHA256 over the **raw request body**, keyed by your per-server secret, sent in the `X-MMOLove-Signature` header (see the `ReferralSignature` security scheme). MMOLove reads the raw bytes and verifies the MAC **before** parsing JSON, so the body you sign must be byte-identical to the body you send — re-serialising the parsed object (changing whitespace / key order) is the #1 integration bug and will fail verification.\n\n**Replay window.** The signature timestamp `t` must be within ±5 minutes of server time, or the request is rejected `401` (`stale`). The MAC is checked before the clock, so a forged timestamp can't probe the window.\n\n**Idempotency.** Deduplication 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 processing, so an exact replay is absorbed and returns `200` (never a `4xx`). Reuse the same `server_event_id` on retry (re-signing with a fresh `t`), and a replay is a no-op.\n\n**First-touch.** The referral anchor is minted once per `(server_id, referee_identity)`. A second referrer registering the same referee is acknowledged `200` and ignored (`{ \"ignored\": \"first_touch_conflict\" }`).",
        "security": [
          {
            "ReferralSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "description": "The lifecycle event. POST the exact bytes you signed.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReferralEvent"
              },
              "examples": {
                "registered": {
                  "summary": "registered — mint the anchor (first-touch)",
                  "value": {
                    "event": "registered",
                    "token": "mmref_abc123",
                    "server_id": "srv_123",
                    "referee_identity": "player42",
                    "server_event_id": "reg-player42",
                    "ts": 1733500000
                  }
                },
                "qualified": {
                  "summary": "qualified — the referee hit your milestone",
                  "value": {
                    "event": "qualified",
                    "token": "mmref_abc123",
                    "server_id": "srv_123",
                    "server_event_id": "qual-player42",
                    "ts": 1733600000
                  }
                },
                "test": {
                  "summary": "test dry-run — fully signature-verified, never written",
                  "value": {
                    "event": "registered",
                    "token": "mmref_abc123",
                    "server_id": "srv_123",
                    "referee_identity": "player42",
                    "server_event_id": "test-1",
                    "ts": 1733500000,
                    "test": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted. Covers a newly-advanced referral, an idempotent duplicate, a first-touch no-op, and a `test` dry-run. Always carries `ok: true`. **Never retry a 200.**",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OkResponse"
                },
                "examples": {
                  "advanced": {
                    "summary": "Anchor minted / advanced",
                    "value": {
                      "ok": true,
                      "referral_id": "8f0e1d2c-3b4a-5e6f-7a8b-9c0d1e2f3a4b",
                      "state": "registered"
                    }
                  },
                  "duplicate": {
                    "summary": "Idempotent replay of the same (token, type, server_event_id)",
                    "value": {
                      "ok": true,
                      "duplicate": true
                    }
                  },
                  "ignored": {
                    "summary": "A different referrer already registered this referee (first-touch wins)",
                    "value": {
                      "ok": true,
                      "ignored": "first_touch_conflict"
                    }
                  },
                  "test": {
                    "summary": "test:true dry-run — verified, never written",
                    "value": {
                      "ok": true,
                      "test": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Malformed request — unreadable/invalid JSON body, a missing or malformed `X-MMOLove-Signature` header, a missing/invalid field, an `event` outside `registered|qualified|reversed`, or a `registered` event without `referee_identity`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "badEvent": {
                    "value": {
                      "error": "event must be one of registered|qualified|reversed"
                    }
                  },
                  "missingIdentity": {
                    "value": {
                      "error": "referee_identity is required for a registered event"
                    }
                  },
                  "badHeader": {
                    "value": {
                      "error": "missing or malformed X-MMOLove-Signature header"
                    }
                  },
                  "unparseable": {
                    "value": {
                      "error": "body is not valid JSON"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Signature rejected — the MAC did not match (`bad_signature`) or the timestamp `t` was outside the ±5-minute replay window (`stale`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "badSignature": {
                    "value": {
                      "error": "signature rejected: bad_signature"
                    }
                  },
                  "stale": {
                    "value": {
                      "error": "signature rejected: stale"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Unknown server (no config for `server_id`), referrals not enabled / no secret configured for it, or an unknown `mmref` token (no click row for this server).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "unknownServer": {
                    "value": {
                      "error": "unknown server"
                    }
                  },
                  "notEnabled": {
                    "value": {
                      "error": "referrals not enabled for this server"
                    }
                  },
                  "unknownToken": {
                    "value": {
                      "error": "unknown referral token for this server"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Invalid state transition — e.g. `qualified` arrived before `registered` minted the anchor. The body carries the `from` state and the rejected `event`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TransitionErrorResponse"
                },
                "examples": {
                  "qualifiedFirst": {
                    "value": {
                      "error": "invalid state transition",
                      "from": "clicked",
                      "event": "qualified"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Internal error on the MMOLove side. Safe to retry with backoff (idempotent via `server_event_id`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "internal": {
                    "value": {
                      "error": "internal error"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {
    "heart.counted": {
      "post": {
        "operationId": "rewardCallback",
        "summary": "Reward callback — a player hearted your server",
        "description": "**Inbound to your server (MMOLove → you).** When a player gives your server a heart and the vote *counts* (after fraud scoring + a 24-hour per-identity cooldown), MMOLove POSTs this signed JSON to the **reward callback URL** you configure on the Integration tab. Verify the signature, then grant the in-game reward to `username`.\n\n**Signature.** Header `X-MMOLove-Signature: t=<unix>,v1=<hex>` — `v1` is a **bare** lower-case hex MAC (note: this differs from the referral ingest endpoint, which prefixes `v1=sha256=`). The MAC is `HMAC_SHA256(secret, \"<t>.<rawBody>\")` over the exact raw body bytes. Verify over the raw body before parsing. Enforcing a ±5-minute window on `t` (replay protection) is recommended on your side. A separate `X-MMOLove-Event` header echoes the event name (`heart.counted` | `heart.test`).\n\n**Idempotency.** Key your grant on `heart_id` so a retried delivery never double-rewards. Return any `2xx` to mark the delivery delivered; a non-2xx (or timeout) is retried with backoff.\n\n**Test mode.** The dashboard's *Send test callback* fires the same shape with `event: \"heart.test\"` — acknowledge it (return 2xx) but don't pay out.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HeartEvent"
              },
              "examples": {
                "counted": {
                  "summary": "A real, counted vote",
                  "value": {
                    "event": "heart.counted",
                    "server_id": "8f0e1d2c-3b4a-5e6f-7a8b-9c0d1e2f3a4b",
                    "username": "PlayerOne",
                    "heart_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                    "period": "2026-06",
                    "timestamp": 1733500000,
                    "streak_day": 7
                  }
                },
                "test": {
                  "summary": "Dashboard 'Send test callback'",
                  "value": {
                    "event": "heart.test",
                    "server_id": "8f0e1d2c-3b4a-5e6f-7a8b-9c0d1e2f3a4b",
                    "username": "PlayerOne",
                    "heart_id": "00000000-0000-0000-0000-000000000000",
                    "period": "2026-06",
                    "timestamp": 1733500000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Your server accepted the delivery (verified + reward granted, or `heart.test` acknowledged). MMOLove marks the delivery **delivered** and stops retrying. Return any `2xx`."
          },
          "400": {
            "description": "Your server could not parse the body or required fields were missing. MMOLove retries with backoff."
          },
          "401": {
            "description": "Your server rejected the signature (raw-body bug, wrong/old secret, or stale `t`). MMOLove retries with backoff."
          },
          "5xx": {
            "description": "Your handler errored. MMOLove retries with backoff."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ReferralSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "X-MMOLove-Signature",
        "description": "HMAC-SHA256 request signature over the **raw request body**, keyed by your per-server referral secret (Dashboard → Referrals tab).\n\n**Header format:** `t=<unix>,v1=sha256=<hex>[,kid=<key-id>]`\n\n- `t` — Unix seconds at signing time. Drives the ±5-minute replay window.\n- `v1` — `sha256=<hex>`, where `<hex>` is `HMAC_SHA256(secret, \"<t>.<rawBody>\")` in lower-case hex. The MAC is computed over the string `\"<t>.<rawBody>\"` (the timestamp, a literal dot, then the raw JSON body) — **not** the body alone.\n- `kid` — *optional* key id, for secret rotation. Carried through; mapping `kid` → secret is yours to manage.\n\nThis is modelled as an `apiKey` header for tooling, but it is a computed HMAC signature, not a static key — see the endpoint description and https://mmolove.com/docs/referral-kit/signing for the exact scheme. The reward-callback webhook uses the same core with a **bare** `v1=<hex>` (no `sha256=` prefix)."
      }
    },
    "schemas": {
      "ReferralEvent": {
        "type": "object",
        "required": ["event", "token", "server_id", "server_event_id"],
        "additionalProperties": true,
        "description": "A referral lifecycle event. Unknown extra fields are not rejected — but the **whole raw body is signed**, so whatever you send must be exactly what you signed.",
        "properties": {
          "event": {
            "type": "string",
            "enum": ["registered", "qualified", "reversed"],
            "description": "The lifecycle event. `registered` mints the first-touch anchor; `qualified` advances + credits the referrer; `reversed` is accepted and modelled (owner reversal) but the current endpoint only acts on `registered`/`qualified`."
          },
          "token": {
            "type": "string",
            "minLength": 1,
            "description": "The `mmref` token your registration page captured. Trimmed; must be non-empty. Must match a referral-click row for this `server_id`, or → 404.",
            "examples": ["mmref_abc123"]
          },
          "server_id": {
            "type": "string",
            "minLength": 1,
            "description": "Your MMOLove server id. Also selects the per-server signing secret used to verify the signature.",
            "examples": ["srv_123"]
          },
          "referee_identity": {
            "type": "string",
            "description": "Your **stable** id for the referred player (account id / username). **Required on `registered`** (the first-touch anchor key, unique per `(server_id, referee_identity)`); ignored on later events. Use a value that never changes — a renamable display name breaks attribution.",
            "examples": ["player42"]
          },
          "server_event_id": {
            "type": "string",
            "minLength": 1,
            "description": "**Your idempotency key** for this event. Dedup is on `(token, type, server_event_id)`. Use a distinct key per logical event (`reg-<id>`, `qual-<id>`) and reuse the same key on retry.",
            "examples": ["reg-player42"]
          },
          "ts": {
            "type": "integer",
            "format": "int64",
            "description": "Unix seconds — *your* event time, for your own logs/ordering. This is **not** the signing `t` (that lives in the header).",
            "examples": [1733500000]
          },
          "test": {
            "type": "boolean",
            "description": "When `true`, the event is fully signature-verified but never writes or advances state. Returns `{ \"ok\": true, \"test\": true }`. This is what the dashboard's *Send test event* button fires.",
            "examples": [true]
          }
        }
      },
      "OkResponse": {
        "type": "object",
        "required": ["ok"],
        "description": "A 200 success. `ok` is always `true`; the remaining fields depend on the outcome.",
        "properties": {
          "ok": {
            "const": true
          },
          "referral_id": {
            "type": "string",
            "format": "uuid",
            "description": "The durable referral id (present when the referral advanced)."
          },
          "state": {
            "type": "string",
            "enum": ["registered", "qualified", "reversed"],
            "description": "The referral's new state (present when it advanced)."
          },
          "duplicate": {
            "type": "boolean",
            "description": "`true` when this was an idempotent replay of an already-seen `(token, type, server_event_id)`."
          },
          "ignored": {
            "type": "string",
            "enum": ["first_touch_conflict"],
            "description": "Set when a different referrer already registered this referee — acknowledged but not applied (first-touch wins)."
          },
          "test": {
            "type": "boolean",
            "description": "`true` when the request was a `test` dry-run (verified, never written)."
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "string",
            "description": "A human-readable error message."
          }
        }
      },
      "TransitionErrorResponse": {
        "type": "object",
        "required": ["error", "from", "event"],
        "description": "A 422 invalid-transition error.",
        "properties": {
          "error": {
            "type": "string",
            "const": "invalid state transition"
          },
          "from": {
            "type": "string",
            "description": "The referral's current state when the event was rejected.",
            "examples": ["clicked"]
          },
          "event": {
            "type": "string",
            "description": "The event that could not be applied from `from`.",
            "examples": ["qualified"]
          }
        }
      },
      "HeartEvent": {
        "type": "object",
        "required": ["event", "server_id", "username", "heart_id", "period", "timestamp"],
        "description": "The reward-callback payload MMOLove POSTs to your server when a heart is counted (or a test callback fires).",
        "properties": {
          "event": {
            "type": "string",
            "enum": ["heart.counted", "heart.test"],
            "description": "`heart.counted` for a real vote; `heart.test` for the dashboard's *Send test callback* (acknowledge, don't pay out)."
          },
          "server_id": {
            "type": "string",
            "format": "uuid",
            "description": "Your MMOLove server id — the server the heart was cast for. Don't reward this; it identifies the server, not the player."
          },
          "username": {
            "type": "string",
            "description": "The voter's in-game name — **the reward recipient**. This is the value the player typed when voting. Resolve it to an account on your side."
          },
          "heart_id": {
            "type": "string",
            "format": "uuid",
            "description": "The vote's unique id. Use it as your **idempotency key** so a retried delivery never double-rewards."
          },
          "period": {
            "type": "string",
            "pattern": "^[0-9]{4}-[0-9]{2}$",
            "description": "The vote's ranking period, `YYYY-MM` (UTC month).",
            "examples": ["2026-06"]
          },
          "timestamp": {
            "type": "integer",
            "format": "int64",
            "description": "Unix seconds at delivery time. Also the `t` used in the signature.",
            "examples": [1733500000]
          },
          "streak_day": {
            "type": "integer",
            "description": "The voter's current daily vote streak (in days) for *this* server at delivery time, so you can grant escalating rewards. **Omitted** when the streak is unavailable — treat a missing field as \"no streak info\", not zero.",
            "examples": [7]
          }
        }
      }
    }
  }
}
