Bonus User API
User-facing REST endpoints under /api/v1/bonus/v1/*. All require JWT auth via Authorization: Bearer <token> issued by the existing /api/v1/auth flow.
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/bonus/v1/status | High-level account snapshot |
GET | /api/v1/bonus/v1/balance-info | Available / locked / withdrawable breakdown |
GET | /api/v1/bonus/v1/history | Paginated attribution events |
POST | /api/v1/bonus/v1/check-order | 60% trip-wire pre-check for a hypothetical order |
POST | /api/v1/bonus/v1/redeem-code | Redeem a campaign code into bonus credit |
POST | /api/v1/bonus/v1/recall-for-withdraw | Strict-mode preflight before vault withdraw |
All money fields are JSON strings (Decimal serialised) to avoid precision loss. Timestamps are ISO-8601 UTC.
Error envelope (any non-200 response):
{ "error": "bonus_user", "code": "snake_case_code", "message": "human readable" }
GET /api/v1/bonus/v1/status
Returns whether the wallet has bonus, current balance, status, and expiry.
Response (has_bonus = false)
{
"has_bonus": false,
"bonus_initial": "0",
"bonus_balance": "0",
"bonus_locked_in_margin": "0",
"bonus_consumed_total": "0",
"bonus_recalled_total": "0",
"max_leverage": 50
}
Response (has_bonus = true)
{
"has_bonus": true,
"bonus_account_id": "uuid",
"status": "active",
"grant_tier": "COMMUNITY",
"bonus_initial": "500",
"bonus_balance": "487.32",
"bonus_locked_in_margin": "120.00",
"bonus_consumed_total": "12.68",
"bonus_recalled_total": "0",
"max_leverage": 50,
"granted_at": "2026-05-13T08:00:00Z",
"expires_at": "2026-05-20T08:00:00Z"
}
Field map:
| Field | Meaning |
|---|---|
bonus_initial | Amount granted at issuance time |
bonus_balance | Bonus principal currently attributable to the user (free + locked) |
bonus_locked_in_margin | Bonus held as position margin |
bonus_consumed_total | Cumulative bonus eaten by trading costs |
bonus_recalled_total | Cumulative bonus returned to BonusPool |
status | active / expired_pending / recalled / frozen |
grant_tier | KOL / COMMUNITY / WAITLIST |
Errors: db_lookup_failed.
Refresh cadence recommendation: every 30 seconds.
GET /api/v1/bonus/v1/balance-info
Returns a free / locked / withdrawable breakdown that combines bonus and principal.
Response
{
"total_available": "1487.32",
"available": "1487.32",
"frozen": "0",
"principal_free": "1000.00",
"principal_locked": "0",
"bonus_free": "487.32",
"bonus_locked": "0",
"effective_withdrawable": "1000.00"
}
effective_withdrawable = principal_free — bonus is never withdrawable. The UI should derive the user's withdraw cap from this field, not from available.
Refresh cadence recommendation: every 10 seconds while the order panel is mounted.
GET /api/v1/bonus/v1/history
Paginated attribution events ordered by occurred_at DESC. Cursor-paged.
Query
limit(default50, clamped1–200)before(ISO-8601 timestamp cursor returned asnext_cursorby the previous page; omit on first request)
Response
{
"rows": [
{
"event_type": "trading_fee",
"total_cost": "0.0240",
"bonus_share": "0.0120",
"principal_share": "0.0120",
"attribution_rule": "50_50",
"source_trade_id": "uuid",
"source_order_id": "uuid",
"occurred_at": "2026-05-14T03:21:08.412Z"
}
],
"next_cursor": "2026-05-14T03:21:08.412Z"
}
event_type enum: trade_loss / trade_pnl_gain / trading_fee / funding_paid / funding_received.
attribution_rule enum: 50_50 / bonus_only / principal_only / no_op.
Errors: db_history_failed.
POST /api/v1/bonus/v1/check-order
Run the 60% net-direction pre-check for a hypothetical order. Idempotent — does not write any state. The frontend should call this before submitting an order; on transient failure, fail open (let the order go; the post-event order_listener cancels violations within seconds).
Body
{
"symbol": "BTCUSDT",
"side": "buy",
"is_opening": true,
"margin_mode": "isolated_hedge"
}
| Field | Notes |
|---|---|
symbol | Accepted for forward-compatibility and audit; rule itself is account-wide |
side | buy / sell (or long / short — backend normalises) |
is_opening | If false, the 60% rule is bypassed (reduce-only) but diagnostic fields still returned |
margin_mode | isolated_hedge / isolated_one_way / unified_hedge / unified_one_way. *_one_way modes also bypass the 60% rule. |
Response (decision = pass)
{
"decision": "pass",
"bonus_balance": "487.32",
"total_available": "1487.32",
"bonus_ratio_pct": "32.77",
"net_direction": "long"
}
Response (decision = reject)
{
"decision": "reject",
"reason_code": "net_direction_violation",
"message": "Bonus exceeds 60% of available; only orders matching current net position direction are allowed.",
"bonus_balance": "950.00",
"total_available": "1100.00",
"bonus_ratio_pct": "86.36",
"net_direction": "short"
}
reason_code is net_direction_violation for 60% rule rejects; other errors return non-200 with error.code instead (e.g. side_invalid, db_check_failed).
POST /api/v1/bonus/v1/redeem-code
Redeem a pre-generated bonus code. Idempotent by request_id.
Body
{
"code": "ABCD2345EFGH",
"request_id": "uuid-v4"
}
| Field | Notes |
|---|---|
code | 1 – 32 chars; backend uppercases / trims |
request_id | 1 – 64 chars; duplicate replays return the original result |
Response (success or replay)
{
"bonus_account_id": "uuid",
"amount": "500",
"granted_at": "2026-05-13T08:00:00Z",
"expires_at": "2026-05-20T08:00:00Z",
"replayed": false
}
replayed = true indicates the same request_id was seen before; the client should treat it as a successful no-op (do not re-credit the UI).
Error codes
code | When |
|---|---|
code_invalid | Code format invalid (length / charset) |
code_not_redeemable | Code missing / already redeemed / expired (expires_at < now) / bound_address mismatch |
request_id_invalid | request_id length not in 1-64 |
db_redeem_failed | Atomic UPDATE failed mid-flight |
already_has_bonus | Wallet already has a bonus_account row (one-bonus-per-wallet rule) — surfaced from activate |
pool_cap_breach / pool_insufficient | BonusPool side-effects of activation failed |
POST /api/v1/bonus/v1/recall-for-withdraw
Strict-mode preflight before a vault.withdraw() call. The frontend must call this and only proceed if it succeeds — failing open here would let bonus get withdrawn.
Body
{ "request_id": "uuid-v4" }
Response
{
"recalled_amount": "487.32",
"bonus_balance_after": "0",
"bonus_locked_after": "0",
"effective_withdrawable": "1000.00",
"replayed": false
}
Users with no bonus get recalled_amount: "0" and proceed normally. Users whose bonus is fully locked in positions get a partial recall (only the free portion) — they must close those positions to recall the locked portion before they can withdraw it as profit-via-principal.
replayed = true on retry — safe to ignore client-side.
Error codes
code | When |
|---|---|
request_id_invalid | Not 1-64 chars |
pool_not_configured | Backend env var BONUS_POOL_ADDRESS missing |
db_lookup_failed | Account lookup failed |
bonus_frozen | bonus_accounts.status = 'frozen' — must unfreeze first |
available_below_recallable | Edge case: balances.available < recallable amount |
recompute_locked_failed | Margin-engine recompute failed |