> ## Documentation Index
> Fetch the complete documentation index at: https://playwave.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Direct API integration

> Integrate with PlayWave by calling HTTP APIs directly without the SDK

This guide shows how to integrate with PlayWave by calling HTTP APIs directly, without using the PlayWave Roblox SDK.

## Integration flow

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant L as PlayWave Launcher
    participant C as Roblox Client
    participant GS as Game Server
    participant API as PlayWave API

    L->>C: Launch game (LaunchData = OTT)
    C->>GS: PlayerAdded
    GS->>GS: Extract OTT from LaunchData
    GS->>API: POST /session/verify (OTT + UserId)
    API-->>GS: game_session_id + is_pc_cafe

    loop Every 2 min
        GS->>API: PATCH /session/heartbeat
        API-->>GS: OK + play_duration_sec
    end

    C->>GS: PlayerRemoving
    GS->>API: DELETE /session/end
    API-->>GS: SUCCESS + play_duration_sec
```

## Prerequisites

* **API Key**: Contact your PlayWave operations representative to request one
* **Roblox Studio**: Game Settings → Security → **Allow HTTP Requests** enabled
* **Server-side only**: All API calls must run in `ServerScriptService`

### API Server URLs

| Environment | URL                                   | Status                                    |
| ----------- | ------------------------------------- | ----------------------------------------- |
| **Dev**     | `https://wave-api.playwave.dev/v1`    | <Badge color="green">Available</Badge>    |
| **QA**      | `https://wave-api-qa.playwave.dev/v1` | <Badge color="yellow">Coming soon</Badge> |
| **Live**    | `https://wave-api.playwave.io/v1`     | <Badge color="yellow">Coming soon</Badge> |

## Authentication

All Game Server API requests require the `X-Api-Key` header.

```
X-Api-Key: {your_api_key}
Content-Type: application/json
```

<Warning>
  API Key must **only be used in ServerScriptService Scripts**. Never place it in `ReplicatedStorage`, `StarterPlayerScripts`, `LocalScript`, or any client-accessible location.
</Warning>

## OTT verification — POST /v1/game/session/verify

Call this first when a player joins the game. It verifies the OTT and creates a game session.

### OTT extraction

The PlayWave launcher passes the OTT UUID directly via `LaunchData`.

```lua theme={null}
local function extractOtt(player)
    local success, joinData = pcall(function()
        return player:GetJoinData()
    end)

    if not success or not joinData then
        return nil
    end

    local launchData = joinData.LaunchData
    if not launchData or type(launchData) ~= "string" or #launchData == 0 then
        return nil
    end

    return launchData
end
```

### Request

```lua theme={null}
local HttpService = game:GetService("HttpService")

local API_URL = "https://wave-api.playwave.dev/v1"
local API_KEY = "your-api-key-here"

local function verifySession(player)
    local ott = extractOtt(player)
    if not ott then
        return { is_valid = false, reason = "NO_OTT" }
    end

    local success, response = pcall(function()
        return HttpService:RequestAsync({
            Url = API_URL .. "/game/session/verify",
            Method = "POST",
            Headers = {
                ["X-Api-Key"] = API_KEY,
                ["Content-Type"] = "application/json",
            },
            Body = HttpService:JSONEncode({
                ott = ott,
                provider_user_id = tostring(player.UserId),
            }),
        })
    end)

    if not success then
        warn("[Playwave] HTTP request failed:", response)
        return { is_valid = false, reason = "NETWORK_ERROR" }
    end

    local decodeOk, body = pcall(function()
        return HttpService:JSONDecode(response.Body)
    end)

    if not decodeOk or not body.success or not body.data then
        return { is_valid = false, reason = "API_ERROR" }
    end

    return body.data
end
```

### Success response

```json theme={null}
{
  "success": true,
  "request_id": "req_...",
  "data": {
    "is_valid": true,
    "game_session_id": "gs_550e8400-e29b-41d4-a716-446655440000",
    "is_pc_cafe": true
  }
}
```

| Field             | Type    | Description                              |
| ----------------- | ------- | ---------------------------------------- |
| `is_valid`        | boolean | Whether verification succeeded           |
| `game_session_id` | string  | Game session ID (used for heartbeat/end) |
| `is_pc_cafe`      | boolean | Whether user is at a PC cafe             |

### Failure response

```json theme={null}
{
  "success": true,
  "request_id": "req_...",
  "data": {
    "is_valid": false,
    "reason": "OTT_EXPIRED"
  }
}
```

<Note>
  Business logic failures also return HTTP 200. Always branch on the `is_valid` value.
</Note>

### reason values

| reason             | Description                                      | Game server action                   |
| ------------------ | ------------------------------------------------ | ------------------------------------ |
| `OTT_EXPIRED`      | OTT expired (over 1 min since issuance)          | Tell user to relaunch from launcher  |
| `OTT_ALREADY_USED` | OTT already consumed                             | Prevent duplicate access — kick user |
| `GAME_MISMATCH`    | OTT game / API Key game mismatch                 | Invalid OTT — kick user              |
| `NOT_CHARGEABLE`   | Billing not possible (insufficient G-coin, etc.) | Tell user to recharge G-coin         |

***

## Heartbeat — PATCH /v1/game/session/heartbeat

Reports that the game session is still active. Must be called every **2 minutes (120 seconds)**.

If heartbeats stop, the session is automatically deleted when the Redis TTL (4 min) expires.

### Request

```lua theme={null}
local function sendHeartbeat(gameSessionId, providerUserId)
    local success, response = pcall(function()
        return HttpService:RequestAsync({
            Url = API_URL .. "/game/session/heartbeat",
            Method = "PATCH",
            Headers = {
                ["X-Api-Key"] = API_KEY,
                ["Content-Type"] = "application/json",
            },
            Body = HttpService:JSONEncode({
                game_session_id = gameSessionId,
                provider_user_id = providerUserId,
            }),
        })
    end)

    if not success then
        warn("[Playwave] Heartbeat request failed:", response)
        return nil
    end

    local decodeOk, body = pcall(function()
        return HttpService:JSONDecode(response.Body)
    end)

    if not decodeOk then
        return nil
    end

    return body
end
```

### Response

```json theme={null}
{
  "success": true,
  "request_id": "req_...",
  "data": {
    "result": "OK",
    "next_heartbeat_in": 120,
    "play_duration_sec": 1800
  }
}
```

| Field               | Type    | Description                                          |
| ------------------- | ------- | ---------------------------------------------------- |
| `result`            | string  | Session status                                       |
| `next_heartbeat_in` | integer | Recommended wait time until next heartbeat (seconds) |
| `play_duration_sec` | integer | Server-side cumulative play time (seconds)           |

### result values

| result             | Description     | Game server action                                    |
| ------------------ | --------------- | ----------------------------------------------------- |
| `OK`               | Normal          | Send next heartbeat after `next_heartbeat_in`         |
| `CHARGE_EXHAUSTED` | G-coin depleted | Notify user. Server auto-terminates after 2 min grace |

### Error responses

| HTTP | Code                | Condition                           | Game server action        |
| ---- | ------------------- | ----------------------------------- | ------------------------- |
| 404  | `SESSION_NOT_FOUND` | Session not found (expired/deleted) | Stop heartbeat, kick user |
| 410  | `SESSION_ENDED`     | Session already ended               | Stop heartbeat, kick user |
| 410  | `SESSION_ENDING`    | Session termination in progress     | Stop heartbeat, kick user |

***

## Session end — DELETE /v1/game/session/end

Call when a player leaves the game. Stops billing and cleans up the session.

### Request

```lua theme={null}
local function endSession(gameSessionId, playDurationSec)
    local requestBody = {
        game_session_id = gameSessionId,
    }

    if playDurationSec then
        requestBody.play_duration_sec = playDurationSec
    end

    local success, response = pcall(function()
        return HttpService:RequestAsync({
            Url = API_URL .. "/game/session/end",
            Method = "DELETE",
            Headers = {
                ["X-Api-Key"] = API_KEY,
                ["Content-Type"] = "application/json",
            },
            Body = HttpService:JSONEncode(requestBody),
        })
    end)

    if not success then
        warn("[Playwave] End session request failed:", response)
        return nil
    end

    local decodeOk, body = pcall(function()
        return HttpService:JSONDecode(response.Body)
    end)

    if not decodeOk then
        return nil
    end

    return body
end
```

### Response

```json theme={null}
{
  "success": true,
  "request_id": "req_...",
  "data": {
    "result": "SUCCESS",
    "play_duration_sec": 1800,
    "client_play_duration_sec": 1795
  }
}
```

| Field                      | Type            | Description                                               |
| -------------------------- | --------------- | --------------------------------------------------------- |
| `result`                   | string          | Always `"SUCCESS"`                                        |
| `play_duration_sec`        | integer         | **Server-side** play time (seconds) — used for settlement |
| `client_play_duration_sec` | integer \| null | Value sent in request. `null` if not provided             |

### Error responses

| HTTP | Code                | Condition                                   | Game server action          |
| ---- | ------------------- | ------------------------------------------- | --------------------------- |
| 404  | `SESSION_NOT_FOUND` | Session not found (already expired/deleted) | Ignore (already cleaned up) |
| 409  | `ALREADY_ENDED`     | Session already ended                       | Ignore (duplicate call)     |

***

## Full implementation example

Add this Script to `ServerScriptService`.

```lua theme={null}
-- ServerScriptService/PlaywaveIntegration (Script)

local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")

-----------------------------------------------------------------
-- Configuration
-----------------------------------------------------------------
local API_URL = "https://wave-api.playwave.dev/v1"
local API_KEY = "your-api-key-here"

local HEARTBEAT_INTERVAL = 120

-----------------------------------------------------------------
-- Per-player session data
-----------------------------------------------------------------
local activeSessions = {}

-----------------------------------------------------------------
-- OTT extraction
-----------------------------------------------------------------
local function extractOtt(player)
    local success, joinData = pcall(function()
        return player:GetJoinData()
    end)

    if not success or not joinData then
        return nil
    end

    local launchData = joinData.LaunchData
    if not launchData or type(launchData) ~= "string" then
        return nil
    end

    return launchData
end

-----------------------------------------------------------------
-- API request helper
-----------------------------------------------------------------
local function apiRequest(method, path, body)
    local requestInfo = {
        Url = API_URL .. path,
        Method = method,
        Headers = {
            ["X-Api-Key"] = API_KEY,
            ["Content-Type"] = "application/json",
        },
    }

    if body then
        requestInfo.Body = HttpService:JSONEncode(body)
    end

    local success, response = pcall(function()
        return HttpService:RequestAsync(requestInfo)
    end)

    if not success then
        warn("[Playwave] HTTP request failed:", response)
        return nil, "NETWORK_ERROR"
    end

    if response.StatusCode >= 400 then
        local errorBody = nil
        pcall(function()
            errorBody = HttpService:JSONDecode(response.Body)
        end)
        return errorBody, "HTTP_" .. tostring(response.StatusCode)
    end

    local decodeSuccess, decoded = pcall(function()
        return HttpService:JSONDecode(response.Body)
    end)

    if not decodeSuccess then
        warn("[Playwave] JSON decode failed:", decoded)
        return nil, "DECODE_ERROR"
    end

    return decoded, nil
end

-----------------------------------------------------------------
-- OTT verification + game session creation
-----------------------------------------------------------------
local function verifySession(player)
    local ott = extractOtt(player)
    if not ott then
        return nil
    end

    local result, err = apiRequest("POST", "/game/session/verify", {
        ott = ott,
        provider_user_id = tostring(player.UserId),
    })

    if err then
        warn("[Playwave] Verify failed for", player.Name, ":", err)
        return nil
    end

    if result and result.success and result.data then
        return result.data
    end

    return nil
end

-----------------------------------------------------------------
-- Heartbeat loop
-----------------------------------------------------------------
local function startHeartbeatLoop(userId, gameSessionId, providerUserId)
    return task.spawn(function()
        while activeSessions[userId] do
            task.wait(HEARTBEAT_INTERVAL)

            if not activeSessions[userId] then
                break
            end

            local result, err = apiRequest("PATCH", "/game/session/heartbeat", {
                game_session_id = gameSessionId,
                provider_user_id = providerUserId,
            })

            if err then
                if err == "HTTP_404" or err == "HTTP_410" then
                    warn("[Playwave] Session lost for UserId", userId, ":", err)
                    activeSessions[userId] = nil
                    local player = Players:GetPlayerByUserId(userId)
                    if player then
                        player:Kick("PlayWave session expired")
                    end
                    break
                end
                warn("[Playwave] Heartbeat error for UserId", userId, ":", err)
                continue
            end

            if result and result.data then
                local hbResult = result.data.result

                if hbResult == "CHARGE_EXHAUSTED" then
                    local player = Players:GetPlayerByUserId(userId)
                    if player then
                        warn("[Playwave] Charge exhausted for", player.Name)
                    end
                end
            end
        end
    end)
end

-----------------------------------------------------------------
-- Session end
-----------------------------------------------------------------
local function endPlayerSession(userId)
    local session = activeSessions[userId]
    if not session then
        return
    end

    activeSessions[userId] = nil

    if session.heartbeatThread then
        task.cancel(session.heartbeatThread)
    end

    local clientPlayDuration = math.floor(os.clock() - session.startTime)

    local result, err = apiRequest("DELETE", "/game/session/end", {
        game_session_id = session.gameSessionId,
        play_duration_sec = clientPlayDuration,
    })

    if err then
        if err ~= "HTTP_404" and err ~= "HTTP_409" then
            warn("[Playwave] End session failed for UserId", userId, ":", err)
        end
    end
end

-----------------------------------------------------------------
-- Event connections
-----------------------------------------------------------------
Players.PlayerAdded:Connect(function(player)
    local data = verifySession(player)

    if not data or not data.is_valid then
        if data and data.reason then
            warn("[Playwave] Verify failed:", player.Name, data.reason)
        end
        return
    end

    local providerUserId = tostring(player.UserId)
    activeSessions[player.UserId] = {
        gameSessionId = data.game_session_id,
        startTime = os.clock(),
        heartbeatThread = nil,
    }

    activeSessions[player.UserId].heartbeatThread = startHeartbeatLoop(
        player.UserId,
        data.game_session_id,
        providerUserId
    )

    -- Grant PC cafe benefits (implement per game)
    if data.is_pc_cafe then
        grantPcCafeBonus(player)
    end
end)

Players.PlayerRemoving:Connect(function(player)
    endPlayerSession(player.UserId)
end)

game:BindToClose(function()
    for userId, _ in pairs(activeSessions) do
        endPlayerSession(userId)
    end
end)

-----------------------------------------------------------------
-- Per-game benefit logic (implement yourself)
-----------------------------------------------------------------
function grantPcCafeBonus(player)
    -- Example: XP boost, bonus coins, exclusive items, etc.
    print("[Playwave] PC cafe bonus granted to", player.Name)
end
```

***

## Security notes

### API Key protection

* API Key must **only be used in `ServerScriptService` Scripts**
* Never place in `ReplicatedStorage`, `StarterPlayerScripts`, `LocalScript`, or any client-accessible location
* Never pass API Key to client via `RemoteEvent`/`RemoteFunction`

### HttpService

* `HttpService:RequestAsync()` can **only be called from ServerScripts** (Roblox security policy)
* Game Settings → Security → **Allow HTTP Requests** must be enabled

### OTT

* OTT is **single-use** — consumed immediately on verify call, cannot be reused
* Must be used within **1 minute (60 seconds)** of issuance
* OTT format: UUID (e.g., `a1b2c3d4-e5f6-7890-abcd-ef1234567890`)
