Skip to content

API: Users, wallet, support

Controller: admin-users.controller.ts. Mount admin/users.

Permission: users:read.

Query:

ParamTypeNotes
qstringEmail / phone / userId / device:… / name
statusenumactive | suspended | banned | deleted
guestenumguest | registered
countryISO-2
createdFromISO date
createdToISO date
sortcreated_at / last_seen_at / balance / spend
orderasc / desc
cursoropaque
limitint, ≤200Default 50

Response:

{
"items": [ /* UserListItem[] */ ],
"nextCursor": "opaque or null",
"total": 1234
}

UserListItem shape: id, email, phoneE164, displayName, countryCode, language, isGuest, status, balanceTotal, totalSpendCents, lastSeenAt, createdAt.

Permission: users:read.

Returns rich UserDetail:

{
"id": "...",
"email": "alice@twistcue.dev",
"phoneE164": null,
"displayName": "Alice",
"avatarUrl": "https://...",
"authProvider": "email",
"countryCode": "PH",
"language": "en",
"isGuest": false,
"status": "active",
"suspendedUntil": null,
"banReason": null,
"tokenVersion": 3,
"createdAt": "...",
"lastSeenAt": "...",
"balanceTotal": 480,
"balancePaid": 320,
"balanceBonus": 160,
"totalSpendCents": 1497,
"unlocksCount": 12,
"streak": { "current": 7, "longest": 14, "lastClaimDate": "..." },
"pushPrefs": { "drops": true, "marketing": false },
"devices": [ /* UserDevice[] */ ],
"subscriptions": [ /* UserSubscription[] */ ],
"strikes": [ /* UserStrike[] */ ]
}

Permission: wallet:read.

Query: limit, cursor.

Response:

{
"items": [
{
"id": "...",
"eventId": "...",
"amount": -30,
"reason": "unlock",
"refType": "episode",
"refId": "0194...",
"createdAt": "..."
}
],
"nextCursor": "opaque or null"
}

Permission: payments:read. Query: limit.

Returns UserPurchase[].

Permission: users:read. Query: limit.

Returns UserWatchHistoryEntry[].

Permission: wallet:adjust.

Request:

{
"amount": 300,
"direction": "grant",
"reason": "apology_bad_video",
"note": "Ticket #4432"
}
  • amount int, 1–1,000,000
  • direction grant | deduct
  • reason 3–200 chars
  • note ≤500 chars, optional

Response 200 (applied):

{
"kind": "applied",
"eventId": "...",
"balanceTotal": 480
}

Response 200 (approval_pending):

{
"kind": "approval_pending",
"approvalId": "...",
"reason": "amount_exceeds_threshold"
}

Response 400 (support cap):

{
"statusCode": 400,
"error": "support_daily_cap_exceeded",
"capRemaining": 200
}

Permission: users:ban.

{
"reason": "Suspicious activity: investigating",
"until": "2026-07-14T00:00:00Z"
}

until optional (null = indefinite).

Permission: users:ban.

{ "reason": "Confirmed fraud: chargeback ring device_id 9d3b..." }

Permission: users:ban.

{ "note": "Cleared after appeal: ticket #7002" }

POST /v1/admin/users/:id/devices/logout-all

Section titled “POST /v1/admin/users/:id/devices/logout-all”

Permission: users:ban.

No body. Bumps token_version, invalidates all JWTs for the user.

Permission: users:anonymize.

{ "reason": "GDPR request: ticket #7841" }

Approval-gated: creates an admin_approval row; a second admin must approve.

MethodPathPerm
GET/admin/users/:id/notesusers:read
POST/admin/users/:id/notesusers:read
DELETE/admin/users/:id/notes/:noteIdusers:read

Body for POST:

{ "body": "Customer confirmed identity via email." }

POST /v1/admin/users/:id/comp-subscription

Section titled “POST /v1/admin/users/:id/comp-subscription”

Permission: users:ban (shared for P3; may be relaxed in a later phase).

{
"days": 30,
"productId": "twistcue_plus_web_monthly",
"reason": "Retention (ticket #9021)"
}