Skip to main content

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.

MethodPathPurpose
GET/api/v1/bonus/v1/statusHigh-level account snapshot
GET/api/v1/bonus/v1/balance-infoAvailable / locked / withdrawable breakdown
GET/api/v1/bonus/v1/historyPaginated attribution events
POST/api/v1/bonus/v1/check-order60% trip-wire pre-check for a hypothetical order
POST/api/v1/bonus/v1/redeem-codeRedeem a campaign code into bonus credit
POST/api/v1/bonus/v1/recall-for-withdrawStrict-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:

FieldMeaning
bonus_initialAmount granted at issuance time
bonus_balanceBonus principal currently attributable to the user (free + locked)
bonus_locked_in_marginBonus held as position margin
bonus_consumed_totalCumulative bonus eaten by trading costs
bonus_recalled_totalCumulative bonus returned to BonusPool
statusactive / expired_pending / recalled / frozen
grant_tierKOL / 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 (default 50, clamped 1200)
  • before (ISO-8601 timestamp cursor returned as next_cursor by 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"
}
FieldNotes
symbolAccepted for forward-compatibility and audit; rule itself is account-wide
sidebuy / sell (or long / short — backend normalises)
is_openingIf false, the 60% rule is bypassed (reduce-only) but diagnostic fields still returned
margin_modeisolated_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"
}
FieldNotes
code1 – 32 chars; backend uppercases / trims
request_id1 – 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

codeWhen
code_invalidCode format invalid (length / charset)
code_not_redeemableCode missing / already redeemed / expired (expires_at < now) / bound_address mismatch
request_id_invalidrequest_id length not in 1-64
db_redeem_failedAtomic UPDATE failed mid-flight
already_has_bonusWallet already has a bonus_account row (one-bonus-per-wallet rule) — surfaced from activate
pool_cap_breach / pool_insufficientBonusPool 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

codeWhen
request_id_invalidNot 1-64 chars
pool_not_configuredBackend env var BONUS_POOL_ADDRESS missing
db_lookup_failedAccount lookup failed
bonus_frozenbonus_accounts.status = 'frozen' — must unfreeze first
available_below_recallableEdge case: balances.available < recallable amount
recompute_locked_failedMargin-engine recompute failed