Skip to main content

Bonus Admin API

Internal REST endpoints under /api/v1/bonus/admin/*. Authenticated by the X-Bonus-Admin-Key request header matching the backend's BONUS_ADMIN_API_KEY env var.

⚠️ Do not send an Authorization: Bearer … header to admin endpoints. The platform mounts a global JWT middleware on the user side, and sending Authorization will cause it to intercept admin calls before the admin-key middleware runs. Use only X-Bonus-Admin-Key.

Every write endpoint is keyed by a caller-supplied request_id (length 1 – 64). bonus_admin_audit_log.request_id is UNIQUE, so duplicate calls return the original result with replayed: true.

Error envelope (any non-200 response):

{ "error": "bonus_admin", "code": "snake_case_code", "message": "human readable" }
MethodPathPurpose
POST/api/v1/bonus/admin/credit-balanceMove USDT from BonusPool into a user's balances row
POST/api/v1/bonus/admin/debit-balanceReverse of credit-balance (incident-mode only)
POST/api/v1/bonus/admin/activateSingle-recipient: credit balance + create bonus_accounts row
POST/api/v1/bonus/admin/grant-batchBatch activate N recipients (calls activate per row)
POST/api/v1/bonus/admin/generate-codesMint codes against an existing grant batch
POST/api/v1/bonus/admin/freezePause writes on a single account
POST/api/v1/bonus/admin/unfreezeResume writes
POST/api/v1/bonus/admin/recallRecall free bonus (partial or full)
GET/api/v1/bonus/admin/reconcile-reportPool flow snapshot

POST /api/v1/bonus/admin/credit-balance

Move USDT from the BonusPool wallet's balances row into a user's. Direct cash transfer; does not create a bonus_accounts shadow ledger row (use activate for that).

Body

{
"target_address": "0x...",
"amount": "500",
"request_id": "uuid",
"batch_id": "uuid (optional)",
"operator_addr": "0xops..."
}

Response

{
"audit_id": "uuid",
"operation": "credit-balance",
"target": "0x...",
"amount": "500",
"replayed": false
}

Invariants enforced atomically: BonusPool net outflow + this credit ≤ BONUS_POOL_CAP_USDT; BonusPool balances.available covers the debit; request_id is fresh.

Errors: amount_invalid / request_id_invalid / pool_not_configured / target_is_pool / pool_cap_breach / pool_insufficient / db_tx_begin / db_commit.


POST /api/v1/bonus/admin/debit-balance

Reverse of credit-balance — pull funds from a user back to BonusPool. Used during incident recovery.

Body

{
"target_address": "0x...",
"amount": "100",
"request_id": "uuid",
"operator_addr": "0xops..."
}

Response — same BalanceMutationResponse shape as credit-balance.

Errors: amount_invalid / request_id_invalid / pool_not_configured / target_is_pool / user_insufficient / db_commit.


POST /api/v1/bonus/admin/activate

Single-recipient bonus activation: debits BonusPool, credits user balance, inserts a bonus_accounts row with a 7-day expiry timer.

Body

{
"recipient_address": "0x...",
"amount": "500",
"grant_batch_id": "uuid",
"grant_tier": "COMMUNITY",
"max_leverage": 50,
"operator_addr": "0xops...",
"request_id": "uuid"
}
FieldNotes
grant_batch_idRequired — must reference an existing bonus_grant_batches row
grant_tierKOL / COMMUNITY / WAITLIST (case-sensitive)
max_leverageOptional override; default comes from system config

Response

{
"bonus_account_id": "uuid",
"audit_id": "uuid",
"granted_at": "2026-05-13T08:00:00Z",
"expires_at": "2026-05-20T08:00:00Z",
"replayed": false
}

Errors: grant_tier_invalid / pool_not_configured / recipient_invalid / amount_invalid / request_id_invalid / already_has_bonus / pool_cap_breach / pool_insufficient / db.


POST /api/v1/bonus/admin/grant-batch

Create a grant batch and immediately activate every wallet in recipients. Internally calls activate per row with a derived per-recipient request_id.

Body

{
"batch_name": "spring2026",
"grant_tier": "COMMUNITY",
"per_address_amount": "500",
"max_leverage": 50,
"recipients": ["0x...", "0x..."],
"operator_addr": "0xops...",
"notes": "Spring 2026 campaign",
"request_id": "spring2026-batch-001"
}
FieldNotes
recipientsNon-empty; ≤ 500 wallets per call
per_address_amountSame amount applied to every recipient
request_idBatch-level idempotency key; each recipient gets its own derived key

Response

{
"grant_batch_id": "uuid",
"created": [
{"address": "0x...", "bonus_account_id": "uuid"}
],
"failed": [
{"address": "0x...", "error_code": "already_has_bonus", "error_message": "..."}
]
}

Note that created and failed are surfaced per recipient; the call returns 200 even if some recipients fail (e.g. duplicates) — the caller inspects the arrays.

Errors: grant_tier_invalid / recipients_empty / recipients_too_many (> 500) / amount_invalid / grants_insert_failed (plus per-recipient activate errors surfaced in failed[].error_code).


POST /api/v1/bonus/admin/generate-codes

Mint redeemable codes against an existing grant_batch_id. The batch is referenced for amount/leverage settings; codes themselves are independent ledger rows in bonus_codes.

Body

{
"grant_batch_id": "uuid",
"amount": "500",
"count": 3000,
"ttl_days": 29,
"bound_addresses": ["0x...", "0x..."],
"operator_addr": "0xops...",
"request_id": "spring2026-codes-001"
}
FieldNotes
count1 – 5000 per call (split larger jobs into multiple request_ids)
amountPer-code USDT value (decimal string)
ttl_daysOptional, default 30, clamped to 1 – 365. bonus_codes.expires_at = now + ttl_days
bound_addressesOptional. If non-null, length must equal count. Each bound_addresses[i] is stored on bonus_codes[i].bound_address and that code is then only redeemable by that wallet.

Response

{
"codes": ["ABCD2345EFGH", "..."],
"audit_id": "uuid",
"replayed": false
}

Codes are 12-char Crockford Base32 ([A-Z2-9], no 0/O/1/I/L). Stored in bonus_codes as SHA-256 hashes — plaintext appears only in this response.

Workflow note: grant-batch always immediately activates its recipients, so you cannot create a "header-only" batch for code distribution. For pure code campaigns, run grant-batch once with a placeholder/internal wallet, take the returned grant_batch_id, mint codes with generate-codes, then recall the placeholder to clean up.

Errors: count_invalid / amount_invalid / bound_length_mismatch / request_id_invalid / code_insert_failed.


POST /api/v1/bonus/admin/freeze

Mark a single bonus account as frozen. While frozen: the expiry / pnl / funding workers skip the account; the order listener rejects new orders; recall-for-withdraw returns bonus_frozen.

Body

{
"target_address": "0x...",
"reason": "suspected abuse — multi-account farming",
"operator_addr": "0xops...",
"request_id": "freeze-uuid"
}

Response

{
"bonus_account_id": "uuid",
"audit_id": "uuid",
"status": "frozen",
"replayed": false
}

Errors: request_id_invalid / bonus_not_found / status_update_failed.


POST /api/v1/bonus/admin/unfreeze

Reverse of freeze. The account returns to whatever pre-freeze status applies (active or expired_pending depending on whether expires_at has elapsed in the interim).

Body — identical shape to freeze.

Errors: request_id_invalid / bonus_not_found / lookup_failed / status_update_failed.


POST /api/v1/bonus/admin/recall

Recall free bonus from a wallet back to BonusPool. By default recalls the full free portion (bonus_balance - bonus_locked_in_margin); pass an amount for a partial recall.

Body

{
"target_address": "0x...",
"amount": "100",
"reason": "campaign-end-cleanup",
"operator_addr": "0xops...",
"request_id": "manual-recall-uuid"
}
FieldNotes
amountOptional. Omit → recall full free portion. If provided, must be ≤ bonus_balance - bonus_locked_in_margin.

Response

{
"bonus_account_id": "uuid",
"recalled_amount": "100",
"bonus_balance_after": "400",
"audit_id": "uuid",
"replayed": false
}

There is no force flag. Bonus locked in open positions can only be released by closing those positions (which triggers the regular settle-and-recall path).

Errors: request_id_invalid / pool_not_configured / bonus_not_found / amount_invalid / amount_above_free / user_insufficient / apply_recall_failed.


GET /api/v1/bonus/admin/reconcile-report

Snapshot of BonusPool flow and outstanding shadow-ledger total. No body, no parameters.

Response

{
"bonus_pool_addr": "0x...",
"total_credited_to_users": "12345.67",
"total_debited_from_users": "1234.56",
"net_outflow": "11111.11",
"pool_cap_usdt": "1500000",
"outstanding_shadow_total": "11111.11"
}

Healthy invariants (for the caller to verify):

  • net_outflow == outstanding_shadow_total (within ε)
  • net_outflow <= pool_cap_usdt
  • outstanding_shadow_total decreases monotonically as users consume / get recalled (modulo new grants)

This endpoint is a read-only report. It does not auto-freeze accounts. The 15-minute background reconcile worker runs a richer invariant suite and may transition accounts to frozen — observe via backend logs (grep 'bonus::reconcile'), not this endpoint.

Errors: pool_not_configured / pool_flow_query.