---
name: mingjue-integration
description: Implement or review a merchant integration with the Mingjue game platform public API — request signing, transfer wallet, single (seamless) wallet callbacks, game launch, records sync and cash rewards. Use when writing, debugging or reviewing code that calls the Mingjue API or implements its wallet callbacks.
---

# Mingjue merchant integration

Full docs: the Mingjue integration site (快速开始 / 游戏 API / 报表及功能 / API Reference). OpenAPI: `/openapi.json` on the same site. When this file and the OpenAPI disagree on a field, the OpenAPI wins.

## Hard rules (never violate)

1. Secrets live only on the merchant server (env var / secret manager). Never in frontend, repo, logs or prompts.
2. Money is a decimal **string** (`"100.00"`), never float. Fraction digits ≤ currency `decimal_scale`; the platform never rounds.
3. A timeout, connection reset, 5xx, 429, `status=UNKNOWN` or `PENDING` does **not** mean the money did not move. Query by the original ID, or resend with the same ID. Never retry with a new `external_id` / `transaction_id`.
4. Every retry re-signs with a fresh `X-Timestamp`; business IDs and `Idempotency-Key` stay the same.
5. Decide outcome from `data.status`, not from HTTP status and not from balance differences.

## Headers

| Header | Rule |
| --- | --- |
| `X-Merchant-Id` | merchant ID |
| `X-Timestamp` | Unix seconds, 10-digit decimal string, e.g. `1790150400`; ±300 s window, else 401 `REQUEST_EXPIRED` |
| `X-Signature` | 64-char lowercase hex, no prefix |
| `Idempotency-Key` | required on every POST; `[A-Za-z0-9._:-]{1,128}` |
| `If-Match` | only on endpoints that require it |
| `Content-Type` | `application/json` when there is a body |

Body: JSON object, UTF-8, no BOM, ≤ 1 MiB, no unknown fields, no duplicate keys, no compression.

## Signature

```
canonical = X-Timestamp + "\n"
          + METHOD + "\n"
          + raw_target + "\n"        # path + "?" + query exactly as sent; not sorted, not decoded
          + (Idempotency-Key or "") + "\n"
          + (If-Match or "") + "\n"
          + raw_body_bytes            # empty for GET; no trailing newline
X-Signature = lowercase_hex(HMAC_SHA256(key = utf8(secret_string), canonical))
```

The secret is a 43-char string; use its UTF-8 bytes directly — do not base64-decode it. Sign the exact bytes you send; do not re-serialize JSON after signing.

Test vector — secret `79QoN9Zotm0vXIlP8jrVvurbqMNY35BROsyd_Jfp9KM`, merchant `M123456789ABC`, `X-Timestamp` `1790150400`:

- `POST /v1/transfers`, Idempotency-Key `order-20260923-0001`, body
  `{"external_id":"order-20260923-0001","player_id":"0192f6a4-3c1e-7b2a-9d40-5e8f1a2b3c4d","currency":"USD","direction":"IN","amount":"100.00"}`
  → `83f744fcdadb9452d99be92347f22c0c8ab6070edbcf0cb0802f12bc664375a4`
- `GET /v1/games?limit=100&status=AVAILABLE` (no Idempotency-Key, no body)
  → `4d0b3c1d96c30a78561074adaf4a4c86645428d919d15fdcd6f738bd7fe1ca10`

Always add a unit test reproducing these vectors.

### Callbacks (platform → merchant)

All callbacks (`/v1/round-events`, `/v1/wallets/balance`, `/v1/wallet-transactions`, `/v1/wallet-transactions/{id}`) use the same headers, the same algorithm and the same delivered `secret`.
Vector: `GET /v1/wallets/balance?currency=USD&external_player_id=merchant-user-1` → `8cd09266cbde8789b023661c95b2c16707433a39c0db2682583daedf85e98b5d`.

Key rotation: during the announced overlap window the platform accepts either the old or the new secret; when verifying callbacks, accept either as well.

Replay protection is the ±300 s window plus business idempotency (`transaction_id` / `external_id` / `Idempotency-Key`); no extra replay store is needed.

Merchant verification order: 1) look up the secret by `X-Merchant-Id` → 401 `AUTHENTICATION_FAILED`; 2) `X-Timestamp` within ±300 s → 401 `REQUEST_EXPIRED`; 3) recompute and constant-time compare → 401 `AUTHENTICATION_FAILED`. Verify against the raw query string and raw body.

## Response envelope

Success: `{"data": {...}, "request_id": "..."}`. Error: `{"error": {"code","message","retryable","details"}, "request_id": "..."}`. Exactly one of `data` / `error`. Branch on `error.code`, never on `message`. `retryable=true` only means the transport may be retried.

Merchant callback responses are decoded strictly: `application/json`, not compressed, `request_id` required, no fields beyond those listed. No redirects.

## Merchant → platform endpoints

| Endpoint | Notes |
| --- | --- |
| `GET /v1/capabilities` | `wallet_mode` TRANSFER\|SEAMLESS, `currencies[{code,decimal_scale}]`, `operations[{kind,supported,reason,schema_version}]`, `limits`. Use as first signed call. |
| `GET /v1/games?status&limit&cursor` | authorized games; show entry only when `launch_supported=true`. Icons via signed `GET /v1/catalog-assets/{asset_id}`. |
| `POST /v1/players` | `{external_player_id, display_name?}` → 201 new / 200 existing `{player_id,...}`. `external_player_id` must be stable. |
| `POST /v1/launches` | `{player_id, game_id, currency, locale?=en, return_url?, display_mode?=full}` → 201 `{launch_id, launch_url, expires_at}`. Open `launch_url` unmodified within 60 s, single use. Refresh/reconnect needs no new launch; a new launch needs a new Idempotency-Key. |
| `POST /v1/transfers` (TRANSFER only) | `{external_id, player_id, currency, direction IN\|OUT, amount}`; `Idempotency-Key == external_id`. 201 with `status`. |
| `GET /v1/transactions/{transaction_id}` | authoritative status `PENDING\|UNKNOWN\|SUCCEEDED\|FAILED`, `failure.code` when FAILED. |
| `GET /v1/transactions?external_id=` | recovery when the transfer response was lost; `items` has 0 or 1. Empty ≠ permission to use a new ID — resend with the same `external_id`. |
| `GET /v1/wallets` / `GET /v1/wallets/{wallet_id}` | TRANSFER: `LEDGER` balance. SEAMLESS: list shows last `OBSERVED` value (may be null); detail calls merchant live, 503 if unconfirmed. `offline_only`/`nonzero_only` TRANSFER only. |
| `GET /v1/records` | first call needs `created_from` (incl.) + `created_to` (excl.), window ≤ 24 h; next pages only `cursor` (+`limit`). Cursor valid 24 h, then 410. 2 req/s shared with record-changes. |
| `GET /v1/record-changes` | exactly one of `since`, `from_now=true`, `cursor`. Persist `next_cursor` (always present). Upsert by `record_id`, keep highest `revision`. 90-day retention → 410 `CURSOR_EXPIRED`. |
| `POST /v1/gift-tasks` | cash reward `{external_id, player_id, currency, amount, reason}`; `Idempotency-Key == external_id`; 201 done / 202 queued. |
| `GET /v1/gift-tasks/{id}` / `POST /v1/gift-tasks/{id}/cancellations` | cancel only while `QUEUED`; else 409 `CANCELLATION_NOT_ALLOWED`. |

Record rebuild (no gaps): `record-changes?from_now=true` → save cursor → read `/v1/records` in 24 h windows → replay changes from the saved cursor, dedupe by `revision`. Never derive the start point from the local clock.

## Single wallet callbacks (merchant implements)

Base: registered HTTPS origin `{MERCHANT_URL}` + fixed paths. Locate players by `external_player_id`; check player/currency belong to the merchant.

### `GET /v1/wallets/balance?currency=..&external_player_id=..`
200 `{balance, currency, as_of}`. Unknown player 404 `RESOURCE_NOT_FOUND`; unsupported currency 422 `CURRENCY_NOT_SUPPORTED`; can't read 503 `DEPENDENCY_UNAVAILABLE`. Never return `"0"` when unsure.

### `POST /v1/wallet-transactions`
Body `{transaction_id, external_player_id, currency, kind, amount, round_id?, reference_transaction_id?, allocations?}`; `Idempotency-Key == transaction_id`.
- `kind`: `BET` debit; `PAYOUT`, `REFUND`, `GIFT` credit. `amount > 0` always.
- `BET` has `round_id`. `PAYOUT` has `round_id` + `allocations[{source_type BET|FREE_ENTITLEMENT, source_id, amount}]` summing to `amount` (BET `source_id` = the BET's `transaction_id`). `REFUND` has `reference_transaction_id` (a SUCCEEDED BET) + original `round_id`; total refunds (succeeded + in-flight) ≤ bet amount, reserved atomically. `GIFT` has no round.
- Response `data` echoes `transaction_id, external_player_id, currency, kind, amount` and adds `status`, `balance`, `updated_at`, `failure?`.
- HTTP 201 ⇒ `SUCCEEDED` or `FAILED`; HTTP 202 ⇒ `PENDING` or `UNKNOWN`.
- `balance` required on `SUCCEEDED`. `failure: {code}` only on `FAILED`.
- Insufficient funds ⇒ **201**, `status=FAILED`, `failure.code=INSUFFICIENT_FUNDS`. An error envelope on this endpoint is treated as UNKNOWN and the platform keeps querying.
- Permanent idempotency on `(merchant, transaction_id)`: same ID + same content ⇒ original HTTP status and original `data` (including the balance at that time) + header `Idempotency-Replayed: true`; same ID + different content ⇒ 409 `IDEMPOTENCY_CONFLICT`.
- Balance change, transaction record and final status commit in **one DB transaction**. Final statuses never change.

### `GET /v1/wallet-transactions/{transaction_id}` (mandatory)
200 with the same `data` shape, read from the authoritative store. Unknown ID ⇒ 404 `TRANSACTION_NOT_FOUND` with `retryable=false`.

### `POST /v1/round-events`
Body `{event_id, external_player_id, currency, round_id, parent_round_id?, type:"ROUND_ENDED", occurred_at}`; `Idempotency-Key == event_id`; signed with the delivered `secret`. Reply 200 `{event_id, accepted_at}`; dedupe by `event_id`. Not a money event and not "round fully settled".

Platform retry: same ID at ~1 s, 5 s, 30 s, 2 m, 10 m, 30 m, then hourly; honors `Retry-After`.

### Enablement probe (must pass before SEAMLESS goes live)
1. bad signature query → 401 `AUTHENTICATION_FAILED`
2. query unknown UUID → 404 `TRANSACTION_NOT_FOUND`
3. same query, correctly signed but with an expired `X-Timestamp` → 401 `REQUEST_EXPIRED`
4. BET of the currency's minimum unit, `round_id=seamless-capability-probe-v1` → 201 `SUCCEEDED` + balance
5. query it → 200, same result and balance
6. replay step 4 → 201 + `Idempotency-Replayed: true`, same balance
7. same `transaction_id`, `round_id=seamless-capability-probe-v1-conflict` → 409 `IDEMPOTENCY_CONFLICT`
8. balance → 200, equal to step 4 balance

## Review checklist

- Signing unit test reproduces all vectors above.
- No secret, signature or `launch_url` in logs.
- All money requests keep a stable business ID across retries; unknown outcomes go to a query/resend loop.
- Callbacks: strict response shape, permanent idempotency, single DB transaction, authoritative query endpoint.
- Records: persisted cursor, revision-based upsert, documented rebuild path.
