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 sendingAuthorizationwill cause it to intercept admin calls before the admin-key middleware runs. Use onlyX-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" }
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/bonus/admin/credit-balance | Move USDT from BonusPool into a user's balances row |
POST | /api/v1/bonus/admin/debit-balance | Reverse of credit-balance (incident-mode only) |
POST | /api/v1/bonus/admin/activate | Single-recipient: credit balance + create bonus_accounts row |
POST | /api/v1/bonus/admin/grant-batch | Batch activate N recipients (calls activate per row) |
POST | /api/v1/bonus/admin/generate-codes | Mint codes against an existing grant batch |
POST | /api/v1/bonus/admin/freeze | Pause writes on a single account |
POST | /api/v1/bonus/admin/unfreeze | Resume writes |
POST | /api/v1/bonus/admin/recall | Recall free bonus (partial or full) |
GET | /api/v1/bonus/admin/reconcile-report | Pool 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"
}
| Field | Notes |
|---|---|
grant_batch_id | Required — must reference an existing bonus_grant_batches row |
grant_tier | KOL / COMMUNITY / WAITLIST (case-sensitive) |
max_leverage | Optional 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"
}
| Field | Notes |
|---|---|
recipients | Non-empty; ≤ 500 wallets per call |
per_address_amount | Same amount applied to every recipient |
request_id | Batch-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"
}
| Field | Notes |
|---|---|
count | 1 – 5000 per call (split larger jobs into multiple request_ids) |
amount | Per-code USDT value (decimal string) |
ttl_days | Optional, default 30, clamped to 1 – 365. bonus_codes.expires_at = now + ttl_days |
bound_addresses | Optional. 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-batchalways immediately activates itsrecipients, so you cannot create a "header-only" batch for code distribution. For pure code campaigns, rungrant-batchonce with a placeholder/internal wallet, take the returnedgrant_batch_id, mint codes withgenerate-codes, thenrecallthe 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"
}
| Field | Notes |
|---|---|
amount | Optional. 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
forceflag. 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_usdtoutstanding_shadow_totaldecreases 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.