# openlivegame Merchant Integration - Agent Spec (Dev)

> Flat, machine-readable quick reference for integrating with **openlivegame**, an
> iGaming **Seamless Wallet** platform. Human doc: <https://doc.open-live.win>. Index: <https://doc.open-live.win/llms.txt>.
>
> **Money model:** every bet / result / refund hits the merchant wallet in real time
> via callback. openlivegame custodies **no** funds - the merchant wallet is the single
> source of truth.

---

## TL;DR

- **Two API surfaces:**
  - **Provider API** - *merchant -> openlivegame*. You CALL these: `gameurl`, `getcasinogames`, `roundreport`, `healthcheck`.
  - **Seamless Wallet** - *openlivegame -> merchant*. You IMPLEMENT these: `authenticate`, `balance`, `bet`, `result`, `refund`.
- **Transport:** HTTP `POST`, `Content-Type: application/x-www-form-urlencoded` (except `healthcheck` = `GET`). All responses are JSON.
- **Auth:** MD5 signature in form field `hash`, both directions. One algorithm (see [Signing](#signing)).
- **`error` type differs by surface:** Provider API = **string** (`"0"` = ok). Wallet = **integer** (`0` = ok). Business errors still return **HTTP 200**.
- **Idempotency (mandatory):** `bet`/`result`/`refund` keyed by `reference` = `B|R|F` + 16-digit `roundId` (17 chars). Enforce with a **DB unique index** on `(operator_id, reference)`.
- **Money:** decimal **strings**, 2 dp (e.g. `"10.50"`). Use Decimal/BigDecimal - **never float**.
- **Timeouts (openlivegame -> merchant):** `bet` = **5 s**, all other callbacks = **10 s**. Keep P99 < 3 s.

---

## Environment (Dev)

| Thing | Value |
|---|---|
| Provider API base (merchant -> openlivegame) | `https://api.open-live.win/provider/v1` |
| Player game entry (gameURL landing) | `https://client.open-live.win` |
| Wallet callback base (openlivegame -> merchant) | merchant-provided `callback_url`, e.g. `https://merchant-wallet.example.com/wallet` |

**Per-merchant config** (agree with openlivegame before integration):

| Field | Purpose | Source |
|---|---|---|
| `secureLogin` | Merchant API identity (Provider API) | openlivegame |
| `secretKey` | Bidirectional signing key - **never transmit / never log** | openlivegame |
| `callback_url` | Base URL of your Seamless Wallet endpoints | merchant |
| `ip_whitelist` | Outbound IPs you call Provider API from (optional, comma-separated) | merchant |
| `currency` | Merchant currency, must be in the supported list | merchant |

**Fixed values:** `providerId` = `ppgame` (sent on every wallet callback).

**Supported currencies:** `USD` (base), `EUR`, `BRL`, `IDR`, `INR`, `USDT` (1:1 pegged to USD).
A currency must be enabled on **both** openlivegame and the upstream game provider before production use; bet limits and wager tiers are configured per currency.

---

## Signing

Field name: **`hash`**. Same algorithm both directions.

```
1. Collect all request params (form-decoded RAW values), EXCLUDING `hash`.
2. Sort param names by ASCII ascending (alphabetical).
3. Join as  k1=v1&k2=v2&...&kn=vn
4. Append secretKey directly to the end (NO separator).
5. hash = lowercase hex MD5(the whole string).
```

**Rules**
- Sign **decoded raw values** (URL-encode only for transport).
- **Merchant verifying openlivegame callbacks:** sign over **every non-`hash` field received** (whatever openlivegame sends, including `timestamp`).
- **Merchant calling Provider API:** optional fields follow each endpoint's "signing participation" rule (empty fields are neither sent nor signed).
- `secretKey` only appears at the tail of the string; it is **never** an HTTP field.

**Verified example** (self-check your implementation):

```
secureLogin=merchant001  token=player-token-123  externalPlayerId=player-001
currency=USD  gameId=545  secretKey=mysecretkey

sorted+joined:
currency=USD&externalPlayerId=player-001&gameId=545&secureLogin=merchant001&token=player-token-123

+secretKey:
currency=USD&externalPlayerId=player-001&gameId=545&secureLogin=merchant001&token=player-token-123mysecretkey

MD5 => ce14a7aa5f8c5c23d92d088e94f76f19
```

**Reference implementations**

```python
import hashlib
def calc_sign(params: dict, secret_key: str) -> str:
    items = sorted((k, v) for k, v in params.items() if k != "hash")
    payload = "&".join(f"{k}={v}" for k, v in items) + secret_key
    return hashlib.md5(payload.encode()).hexdigest()
```

```javascript
const crypto = require('crypto');
function calcSign(params, secretKey) {
  const payload = Object.keys(params)
    .filter(k => k !== 'hash').sort()
    .map(k => `${k}=${params[k]}`).join('&') + secretKey;
  return crypto.createHash('md5').update(payload).digest('hex');
}
```

```go
func CalcSign(params map[string]string, secretKey string) string {
    keys := make([]string, 0, len(params))
    for k := range params { if k != "hash" { keys = append(keys, k) } }
    sort.Strings(keys)
    var b strings.Builder
    for i, k := range keys {
        if i > 0 { b.WriteByte('&') }
        b.WriteString(k); b.WriteByte('='); b.WriteString(params[k])
    }
    b.WriteString(secretKey)
    sum := md5.Sum([]byte(b.String()))
    return hex.EncodeToString(sum[:])
}
```

```php
function calcSign(array $params, string $secretKey): string {
    unset($params['hash']); ksort($params);
    $parts = [];
    foreach ($params as $k => $v) { $parts[] = $k.'='.$v; }
    return md5(implode('&', $parts).$secretKey);
}
```

---

## Provider API  (merchant -> openlivegame)

Base: `https://api.open-live.win/provider/v1`. POST + form (except healthcheck). Response JSON, `error` is a **string**, `"0"` = success. Only the `/provider/v1/*` prefix is accepted.

### POST `/gameurl` - get player game launch URL

Flow: you call `gameurl` -> openlivegame calls your `/authenticate` (same `token`) -> on success returns a `gameURL` (carries `JSESSIONID`); 302-redirect the player to it.

| Param | Type | Req | Notes |
|---|---|---|---|
| `secureLogin` | string | required | merchant identity |
| `token` | string | required | one-time player token; passed through to `/authenticate` |
| `externalPlayerId` | string | required | merchant player id, regex `^[A-Za-z0-9_\-]{1,64}$`. **One `externalPlayerId` binds exactly one `currency` for life.** |
| `currency` | string | required | ISO code; must equal the `currency` your `/authenticate` returns, else `error=20` |
| `hash` | string | required | signature |
| `gameId` | string | optional | game table code (from `getcasinogames`). Empty -> lobby; set -> that table. **Always signed, even when empty (`gameId=`).** |
| `language` | string | optional | default `en`. **Always signed**: if omitted, openlivegame recomputes with `language=en` -> either send it explicitly or include `language=en` in your hash. |
| `country` | string | optional | ISO country; signed only if non-empty |
| `platform` | string | optional | `mobile`/`desktop`/...; signed only if non-empty |
| `lobbyUrl` | string | optional | return-to-lobby URL; signed only if non-empty |
| `lobby` | string | optional | `1` = lobby/category list only. **Signed only when value is `1`; never send `0`.** |

**Response:** `error` (string), `description`, `gameURL` (only when `error="0"`).

**Hard validations** (openlivegame performs no tolerant matching):
- `/authenticate`'s `userId` **must == this request's `externalPlayerId`**, else `gameurl` -> `error=4`.
- `/authenticate`'s `currency` must match this `currency` (case-insensitive), else `error=20`.
- **One user, one currency:** first entry freezes `(externalPlayerId, currency)`. Multi-currency users need a **distinct `externalPlayerId` per currency** - convention `{originalUserID}_{currency}` (e.g. `u12345_USD`, `u12345_EUR`); merchant maintains the natural-person->IDs mapping.

```bash
curl -X POST https://api.open-live.win/provider/v1/gameurl \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "secureLogin=merchant001" -d "token=player-token-xyz" \
  -d "externalPlayerId=player-001" -d "currency=USD" \
  -d "gameId=545" -d "language=en" \
  -d "hash=e768a4a5f34aec8263db58b7d2775f30"
# => {"error":"0","description":"OK","gameURL":"https://client.open-live.win/desktop/baccarat/?JSESSIONID=...&table_id=545&lang=en"}
```

### POST `/getcasinogames` - full enabled-table list

Params: `secureLogin` required, `hash` required, `gameType` (optional filter e.g. `roulette`/`baccarat`/`blackjack`, signed only if non-empty).

Response `gameList[]`: `gameId` (= `gameurl` `gameId` input & wallet `tableCode`), `operatorGameId` (upstream vendor game id), `gameName`, `gameType`, `typeDescription`, `vendorType` (`pp`/`evo`), `status` (always `open`).

### POST `/roundreport` - one-time report URL for a round

Params: `secureLogin` required, `roundId` required (the business round number from wallet callbacks, 16 digits), `hash` required.
Response: `error` (`"4"` = round not under this merchant; `"7"` = bad `roundId` format), `description`, `url` (only when `error="0"`; carries a short-lived token).
Ownership isolation: querying another merchant's round -> `error=4`.

### GET `/healthcheck` - liveness

No params, no signature. `GET https://api.open-live.win/provider/v1/healthcheck` -> `{"error":"0","description":"OK"}`.

---

## Seamless Wallet  (openlivegame -> merchant - **you implement these**)

Base: your `callback_url`. POST + form. Sign `hash` over **all non-`hash` fields received**.
**Response:** JSON, **HTTP 200 even for business errors** (put the code in `error`), `error` is an **integer**, amounts are JSON numbers <= 2 dp.
**Mandatory idempotency** on `reference` for `bet`/`result`/`refund` - duplicates must return the **same** `transactionId` + `cash`; never double-debit/credit. (`/authenticate`, `/balance` are read-only / naturally idempotent.)

### POST `/authenticate` - validate token, return initial balance

Request: `token`, `providerId` (`ppgame`), `hash`.
Response: `userId` (**must == gameurl `externalPlayerId`**), `currency` (must match gameurl; immutable per `userId`), `cash` (number, 2 dp), `error` (int), `description`.
Failure example: `{"userId":"","currency":"","cash":0,"error":3,"description":"invalid token"}` -> gameurl returns `error=4`.

### POST `/balance` - latest balance (session recovery / refresh)

Request: `userId`, `providerId` (`ppgame`), `hash`.
Response: `currency`, `cash`, `error`, `description`.

### POST `/bet` - debit (timeout **5 s**)

Player confirms a bet -> **deduct** `amount`.

| Param | Type | Notes |
|---|---|---|
| `userId` | string | = `externalPlayerId` |
| `tableCode` | string | game table code (= `getcasinogames.gameId`). **Field is `tableCode`, not `tableId`.** |
| `gameId` | string | vendor round number - unique **within a table**; **may repeat across tables**. Unique-per-round = `(tableCode, gameId)`. |
| `roundId` | string | business round number - **unique per player per round**, 16 digits; derived from `(tableCode, gameId, userId)`. |
| `amount` | string(decimal) | 2 dp, e.g. `"10.50"` |
| `reference` | string | idempotency key = `"B" + roundId` (17 chars) |
| `providerId` | string | `ppgame` |
| `timestamp` | string(int64) | ms epoch |
| `hash` | string | signature |
| `roundDetails` | string(json) | reserved; **not sent in this version**; if present, treat as optional but **still sign it** |

Response: `transactionId`, `currency`, `cash` (after debit), `error` (int), `description`.
**Insufficient balance:** do **not** debit; return current real `cash` + `error=1`.
**Uncertain status:** if you time out (5 s) or return `error=100`/unknown, openlivegame treats the debit as uncertain and queues a `/refund` offset (`reference="F"+roundId`). Make `/bet` "posted-or-cleanly-failed" and `/refund` idempotency robust.

### POST `/result` - credit / settle

Called for **every player with a successful bet**, including **losers** (`amount="0.00"`) - still record a zero-credit row and return success so both sides confirm the round settled.

Fields = `bet` minus `roundDetails`: `userId`, `tableCode`, `gameId`, `roundId`, `amount` (>=0, 2 dp), `reference` = `"R" + roundId`, `providerId`, `timestamp`, `hash`.
Response: same shape as `bet`.
**Retry:** network failure -> immediate retry up to 3 (2 s/4 s backoff); business errors not retried immediately; final failures -> async queue (~15 s scan, up to 5), then manual. openlivegame guarantees a matching successful `bet` exists before calling `result`.

### POST `/refund` - rollback / credit back

Triggers: (1) whole round voided (interruption / upstream cancel); (2) `bet` uncertain (timeout / `error=100`).
Fields = `result` (with `reference` = `"F" + roundId`); `amount` is **positive** (credit back).
Response: same shape as `bet`. `error=0` and `error=5` (duplicate) both = success.
**Validate via `(tableCode, gameId, userId)` + `txn_type='bet'`** (equivalently `roundId` + `txn_type='bet'`), **not** by comparing the refund `reference` to the bet table (prefixes differ).
**If the bet was never actually posted** on your side (bet-timeout case): return **`error=2`** - do **not** credit out of nowhere. openlivegame escalates to manual.

---

## Error codes

**Provider API - string** (openlivegame -> merchant response):

| `error` | Meaning | When |
|---|---|---|
| `"0"` | success | - |
| `"1"` | internal error | service/db exception |
| `"2"` | invalid `secureLogin` | not registered / wrong |
| `"3"` | merchant disabled | merchant or org disabled |
| `"4"` | resource not found | authenticate fail / `userId`!=`externalPlayerId` / round not found |
| `"5"` | signature error | `hash` mismatch |
| `"7"` | bad param format | `externalPlayerId` regex / `roundId` format |
| `"14"` | missing required field | required param absent / bind failed |
| `"20"` | currency mismatch | `externalPlayerId` already bound elsewhere, or authenticate currency != request |

**Seamless Wallet - integer** (merchant -> openlivegame response):

| `error` | Meaning | openlivegame handling |
|---|---|---|
| `0` | success | continue |
| `1` | insufficient balance | reject bet, notify player |
| `2` | user not found | terminate session |
| `3` | invalid token | gameurl -> `error=4`, reject entry |
| `4` | signature error | log, check `secretKey` |
| `5` | duplicate transaction | idempotent success; use this response |
| `100` | internal error | uncertain: bet -> refund offset; result/refund -> retry queue |

---

## Idempotency & ordering (mandatory reading)

**Unique key:** `UNIQUE (operator_id, reference)` on the transactions table. `reference` is globally unique per merchant (`B`/`R`/`F` prefix isolates the three endpoints). App-level `if exists` **without** a DB unique index fails under concurrency.

**Three duplicate scenarios - return by actual state, never re-run business logic:**

| Scenario | Detect | Respond |
|---|---|---|
| A / already succeeded | same `reference`, status `success` | return original `transactionId` + balance; `error=0` (or `5`) |
| B / already failed | same `reference`, status `failed` (e.g. insufficient) | return the **same failure** (e.g. `error=1` + current balance). Do **not** re-run - avoids a late bet becoming valid after a top-up. |
| C / concurrent | two threads, same `reference` | rely on the DB unique index: one inserts, the other hits the conflict -> re-read and return per Scenario A |

**Bet pseudocode (result/refund are isomorphic - credit not debit, no insufficient branch; `result.amount` may be 0):**

```sql
BEGIN;
-- Step 1: idempotency lookup (FOR UPDATE) -> Scenario A/B
tx := SELECT * FROM wallet_transactions
      WHERE operator_id=$op AND reference=$ref FOR UPDATE;
if tx exists: return {transactionId:tx.id, cash:tx.cash_after, currency:tx.currency,
                      error:tx.error_code, description:tx.description};
-- Step 2: lock player row
player := SELECT * FROM players WHERE operator_id=$op AND user_id=$userId FOR UPDATE;
-- Step 3: business check (record a FAILED row too, so retries hit Scenario B)
if player.cash < $amount:
   INSERT ... VALUES (..., 'bet', $amount, player.cash, player.cash, 1, 'insufficient balance', 'failed');
   COMMIT; return {cash:player.cash, error:1, description:"insufficient balance"};
-- Step 4: debit + write txn in the SAME transaction
UPDATE players SET cash = cash - $amount WHERE id = player.id;
txId := INSERT ... VALUES (..., 'bet', $amount, player.cash, player.cash-$amount, 0, 'OK', 'success')
        ON CONFLICT (operator_id, reference) DO NOTHING RETURNING id;
if txId is null: ROLLBACK; goto Step 1;   -- Scenario C
COMMIT; return {transactionId:txId, cash:player.cash-$amount, currency:player.currency, error:0};
```

**Ordering:** normal `bet -> result` (incl. `amount=0` losers); abnormal `bet -> refund` (void or uncertain bet). Same round won't normally get both `result` and `refund`.

**Anti-patterns:** app-only `if exists` (no DB unique index) / re-running business logic on retries / debit and txn-write in separate transactions / float math / HTTP 4xx/5xx for business errors (use 200 + `error`) / `userId` != `externalPlayerId` / same `externalPlayerId` with different `currency` / using callback `gameId` as a global unique round key (it repeats across tables -> use `(tableCode, gameId)` or `roundId`).

**Reconciliation:** T+1 on `(operator_id, date, txn_type)` totals (amount + count); fix discrepancies same-day.

---

## Go-live checklist (condensed)

- [ ] `secureLogin` / `secretKey` / `callback_url` agreed & stored
- [ ] 5 wallet endpoints implemented: `authenticate` `balance` `bet` `result` `refund`
- [ ] All callbacks verify `hash` over every non-`hash` field; failure -> `error=4`
- [ ] Signing reproduces `ce14a7aa5f8c5c23d92d088e94f76f19` for the verified example
- [ ] token one-time, short-lived, player-bound
- [ ] `authenticate.userId == externalPlayerId`; `currency` matches gameurl
- [ ] multi-currency users use distinct `externalPlayerId` (`uid_USD` / `uid_EUR`)
- [ ] callbacks read `tableCode` (not `tableId`)
- [ ] `bet`/`result`/`refund` idempotent via `UNIQUE (operator_id, reference)`
- [ ] `result` with `amount="0.00"` records + returns success
- [ ] debit/credit + txn-write in one DB transaction
- [ ] duplicates return the first persisted result/error (no re-run)
- [ ] failures (insufficient, etc.) persist a `failed` row
- [ ] refund with no matching bet -> `error=2` (no phantom credit)
- [ ] all business errors -> HTTP 200 + `error`
- [ ] balances use Decimal, not float
- [ ] callback P99 < 3 s (bet hard timeout 5 s)
- [ ] prod IP allowlist (if any) reported to openlivegame
- [ ] sandbox tests done: success / insufficient / duplicate `reference` / refund

**Monitor:** per-endpoint QPS/P99/error-rate / `error=1` daily share / `error=5` count (spike -> contact openlivegame) / refund-without-matching-bet count (spike -> slow bet path) / signature-failure count (possible key leak/attack).
