Shuffly API (1.0.0)

Download OpenAPI specification:

Shuffly mobile app backend API. Covers new-format controllers only (friends, voice rooms, auth, wallet, rewards, allowances, and the admin surface). The WebSocket protocol (see top navbar) drives voice-room presence and per-seat timers. The Game-Server API (also in the top navbar) is a server-to-server surface for sibling game backends — wallet balance, entry-fee debits, and settle/payout (separate page, separate X-Game-Server-Key auth).

auth

Token refresh, logout, and social-login (Google + Apple) callbacks

Refresh JWT access token

Send the refresh token in the JSON body as { "refreshToken": "..." }. For clients that prefer the header convention, Authorization: Bearer <refreshToken> is accepted as a fallback. The body takes precedence when both are present — this prevents auto-attached JWT Bearer headers (common in mobile HTTP clients) from being mistakenly treated as a refresh credential.

Authorizations:
NoneBearerAuth
Request Body schema: application/json
optional
refreshToken
required
string

The refresh token from the last token pair.

Responses

Request samples

Content type
application/json
Example
{
  • "refreshToken": "rt_9f3c1a7b2e5d4086b1c3a9f7e2d5c840b6a1938f4e7c2d05"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "Token yenilendi.",
  • "data": {
    }
}

Revoke refresh token (logout)

Send the refresh token in the JSON body as { "refreshToken": "..." }. For clients that prefer the header convention, Authorization: Bearer <refreshToken> is accepted as a fallback. The body takes precedence when both are present.

Authorizations:
NoneBearerAuth
Request Body schema: application/json
optional
refreshToken
required
string

The refresh token from the last token pair.

Responses

Request samples

Content type
application/json
Example
{
  • "refreshToken": "rt_9f3c1a7b2e5d4086b1c3a9f7e2d5c840b6a1938f4e7c2d05"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "Çıkış yapıldı.",
  • "data": {
    }
}

Sign in with Google (mobile)

Mobile sign-in via a Google ID token obtained by the client through the google_sign_in Flutter package. The server verifies the token's signature against Google's JWKS, asserts iss and aud, and on first sign-in creates a users row with a synthetic +999... callerid plus matching user_identities and user_profiles rows. Returns the same token bundle as the OTP login path so downstream code is identical. Public — no JWT required (this IS the login).

Request Body schema: application/json
required
idToken
required
string

Google ID token (JWT) issued by the google_sign_in SDK on the device.

displayName
string or null

Client-supplied hint, used as user_profiles.full_name when no name claim is present in the verified token.

deviceId
string

Stable client-side device identifier (e.g. a UUID persisted in local storage). Used as the user_devices.device_id upsert key. Optional — when omitted but firebaseToken is present, the server synthesises a stable id of the form auto-<sha256(userId:firebaseToken)>.

deviceType
string

Free-form (e.g. android, ios). Stored on user_devices.device_type and user_sessions.platform for triage.

firebaseToken
string

FCM registration token from FirebaseMessaging.instance.getToken(). Persisted in user_devices.firebase_token so the DM-push dispatcher can target this device. Tokens that fail a structural sanity check (placeholder strings, truncated values) are silently dropped while the device row is still upserted. See POST /api/account/fcm-token for the rotation/backfill path.

versionNo
string

App version string for triage (e.g. 9.3.2).

appVersion
string

Alias for versionNo.

Responses

Request samples

Content type
application/json
Example
{
  • "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjBhMWIyYyJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20ifQ.sig"
}

Response samples

Content type
application/json
Example
{
  • "userId": "+999102938475610",
  • "uuid": "550e8400-e29b-41d4-a716-446655440000",
  • "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIrOTk5MTAyOTM4NDc1NjEwIn0.7sK3",
  • "refreshToken": "rt_9f3c1a7b2e5d4086b1c3a9f7e2d5c840b6a1938f4e7c2d05",
  • "tokenType": "Bearer",
  • "expiresAtMs": 1785328200000,
  • "refreshExpiresAtMs": 1787918400000,
  • "isNewUser": true,
  • "profile": {}
}

Sign in with Apple (mobile)

Mobile sign-in via an Apple identity token obtained by the client through the sign_in_with_apple Flutter package. The server verifies the token against Apple's JWKS, asserts iss=https://appleid.apple.com and the configured bundle ID as aud, then creates or returns a user as with Google. Apple emits the user's name only on the very first sign-in, so the client may pass givenName/familyName as a fallback for display_name. The user's email may be a private-relay address; this is reported via profile.isPrivateRelay. Public — no JWT required (this IS the login).

Request Body schema: application/json
required
identityToken
required
string

Apple identity token (JWT) issued by the sign_in_with_apple SDK on the device.

authorizationCode
string or null

Optional Apple authorization code. Reserved for a follow-up that exchanges it for a refresh token (account-deletion / revoke flow). Currently accepted but not used.

givenName
string or null

Apple sends names only on the very first sign-in; clients should cache and pass them here so the profile can be populated.

familyName
string or null

See givenName.

deviceId
string

Stable client-side device identifier (e.g. a UUID persisted in local storage). Used as the user_devices.device_id upsert key. Optional — when omitted but firebaseToken is present, the server synthesises a stable id of the form auto-<sha256(userId:firebaseToken)>.

deviceType
string

Free-form (e.g. android, ios). Stored on user_devices.device_type and user_sessions.platform for triage.

firebaseToken
string

FCM registration token from FirebaseMessaging.instance.getToken(). Persisted in user_devices.firebase_token so the DM-push dispatcher can target this device. Tokens that fail a structural sanity check (placeholder strings, truncated values) are silently dropped while the device row is still upserted. See POST /api/account/fcm-token for the rotation/backfill path.

versionNo
string

App version string for triage (e.g. 9.3.2).

appVersion
string

Alias for versionNo.

Responses

Request samples

Content type
application/json
Example
{
  • "identityToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjhkOWYxYSJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIn0.sig",
  • "givenName": "Ayşe",
  • "familyName": "Yılmaz"
}

Response samples

Content type
application/json
Example
{
  • "userId": "+999504837261950",
  • "uuid": "9b2d4f60-1c3a-4e78-9f05-2a7c6d8e1b34",
  • "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIrOTk5NTA0ODM3MjYxOTUwIn0.7sK3",
  • "refreshToken": "rt_2c7e9a1f5b3d8046e2a9c4f7b1d5e830a6f2947c3e8b1d0a",
  • "tokenType": "Bearer",
  • "expiresAtMs": 1785328200000,
  • "refreshExpiresAtMs": 1787918400000,
  • "isNewUser": true,
  • "profile": {
    }
}

otp

Phone OTP send + verify (login flow)

Send a phone OTP

Generates a 6-digit code, hashes and stores it in otp_requests, then sends it via SMS (vendor-specific routing for 90* numbers). Rate-limited: one send per phone per otp_resend_cooldown_seconds (system_config knob, default 60), and at most otp_max_hourly_requests per hour (default 10). A Memcached lock with the same TTL as the resend cooldown prevents duplicate sends from the same phone.

Public route — no JWT required. The same endpoint is the canonical "resend" surface — call it again once the cooldown has elapsed.

Successful responses include resendAvailableAtMs (epoch ms) — the earliest time at which a subsequent call to this endpoint will be accepted. Clients should use it to gate a "Resend code" button. The expiresAt field is the OTP TTL (configurable via otp_ttl_minutes, default 5 minutes).

When debug_otp_return=true (system_config), the response also includes the plaintext OTP under debugOtp — never enable in production.

Request Body schema: application/json
required
telefon
required
string

Phone number, E.164-style without + (e.g. 905551234567). phone is also accepted.

phone
string

Alias for telefon.

Responses

Request samples

Content type
application/json
Example
{
  • "telefon": "905321234567"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "OTP gönderildi.",
  • "expiresAt": "2026-07-27 12:05:00",
  • "resendAvailableAtMs": 1785326460000,
  • "debugOtp": null,
  • "serviceResponse": "queued:1"
}

Verify the OTP and issue session tokens

Validates the user-supplied OTP against the most recent unused otp_requests row for the phone. On success: creates the user if new, upserts user_identities/user_profiles, registers/refreshes user_devices if deviceId is supplied, mints JWT + refresh tokens via AuthTokenService, and writes a user_sessions row.

Public route — no JWT required (this IS the login).

/otpCheck is an alias for the same handler, kept for legacy clients.

Request Body schema: application/json
required
telefon
required
string
phone
string

Alias for telefon.

otp
required
string

6-digit code.

kod
string

Alias for otp.

deviceId
string
deviceType
string

Free-form (e.g. android, ios).

firebaseToken
string
versionNo
string
appVersion
string

Alias for versionNo.

Responses

Request samples

Content type
application/json
Example
{
  • "telefon": "905321234567",
  • "otp": "481902",
  • "deviceId": "a3f1c9d2e8b4",
  • "deviceType": "android",
  • "versionNo": "2.14.0"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "Giriş başarılı.",
  • "data": {
    }
}

Alias of /otpSmsCheck

Same handler as /otpSmsCheck. Legacy alias.

Request Body schema: application/json
required
telefon
required
string
phone
string

Alias for telefon.

otp
required
string

6-digit code.

kod
string

Alias for otp.

deviceId
string
deviceType
string

Free-form (e.g. android, ios).

firebaseToken
string
versionNo
string
appVersion
string

Alias for versionNo.

Responses

Request samples

Content type
application/json
Example
{
  • "telefon": "905321234567",
  • "otp": "481902",
  • "deviceId": "a3f1c9d2e8b4",
  • "deviceType": "android",
  • "versionNo": "2.14.0"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "Giriş başarılı.",
  • "data": {
    }
}

user

User account and profile management

Update user + profile fields (admin-style)

Updates whitelisted columns on users (callerid, status, is_deleted, last_login_at) and/or user_profiles (username, full_name, gender, second_language, birth_date, avatar_id, bio, rating). userId (users.id) is required.

Setting avatar_id is ownership-gated — the caller must already own the target avatar (purchase via POST /v1/avatars/{avatarId}/buy first). Invalid or unowned ids return 403 / 404.

The old free-text avatar_url field was dropped — avatar_url is now a derived response field emitted via JOIN from user_profiles.avatar_id → avatars.image_url.

Most mobile clients should use /userUpdateProfile (profile-only, with stricter validation) — this endpoint is the broader admin variant.

Setting username claims a public handle: it must be free (case-insensitively) and must not match the reserved ^Newbie\d+$ provisioning shape, otherwise 409 / 422. An accepted username is mirrored onto users.display_name, which is UNIQUE as well.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
required
integer
callerid
string
status
string
is_deleted
integer
Enum: 0 1
last_login_at
string
username
string
full_name
string
gender
string
Enum: "male" "female" "other"
second_language
string
birth_date
string

YYYY-MM-DD.

avatar_id
integer

Ownership-gated — must be owned by the caller.

avatarId
integer

Alias.

bio
string
rating
number
property name*
additional property
any

Responses

Request samples

Content type
application/json
Example
{
  • "userId": 1042,
  • "username": "gece_kusu",
  • "full_name": "Ayşe",
  • "gender": "female",
  • "bio": "Bugün buradayız."
}

Response samples

Content type
application/json
{}

Update profile fields (mobile client)

Profile-only update with input validation (gender enum, birth_date format YYYY-MM-DD, bio ≤ 500 chars). Empty fields are ignored — only supplied fields are touched. Inserts a user_profiles row if none exists yet.

birth_date is never required, and it is clearable: sending the key with an explicit null writes NULL — "no selection".

An empty string is not a clear. "" keeps its legacy "not supplied" meaning and leaves the stored date alone, because the shipped Flutter client posts birthDate on every save and sends "" whenever its cached profile has no parsed date. Only null clears.

Setting avatar_id is ownership-gated — caller must own the target avatar (purchase via POST /v1/avatars/{avatarId}/buy first).

The old avatar_url/profileAvatarUrl free-text write fields are gone — avatar URL is now derived in responses via user_profiles.avatar_id → avatars.image_url JOIN.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
required
integer
full_name
string
fullName
string

Alias.

username
string
bio
string <= 500 characters
about
string

Alias for bio.

birth_date
string or null

YYYY-MM-DD. Optional; send an explicit null to clear. "" means "not supplied".

birthDate
string or null

Alias.

gender
string
Enum: "male" "female" "other"
second_language
string
secondLanguage
string

Alias.

avatar_id
integer

Ownership-gated — must be owned by the caller.

avatarId
integer

Alias.

Responses

Request samples

Content type
application/json
Example
{
  • "userId": 1042,
  • "full_name": "Ayşe",
  • "bio": "Bugün buradayız.",
  • "birth_date": "1998-04-12",
  • "gender": "female"
}

Response samples

Content type
application/json
{}

Moderate a chosen username (synchronous AI + rule check)

Proxies the caller's chosen username to the AI + rule-set moderation service and returns the decision in the same request. The client calls this on submit; on approved: true it saves the profile via /userUpdateProfile (which re-verifies the approval server-side).

callerid is taken from the JWT, never the body. The moderation service is firewalled to the backend and rate-limited, so the client must NOT call it directly.

  • approved: true → the name may be saved.
  • approved: false + retry: true → the check could not run (service unavailable / rate-limited); the same name may be retried.
  • approved: false (no retry) → rule rejection; reason (Turkish) and category explain why. Ask the user for a new name.

Availability is decided first. user_profiles.username and users.display_name are UNIQUE (case-insensitively — the columns are utf8mb4_unicode_ci). If the name is already claimed by someone else, or matches the reserved ^Newbie\d+$ provisioning shape, the response is {"approved": false, "available": false} and no AI call is made. The caller's own current name reads as available. A name that passes both gates returns available: true alongside the AI decision.

Authorizations:
BearerAuth
Request Body schema: application/json
required
name
required
string

Responses

Request samples

Content type
application/json
Example
{
  • "name": "gece_kusu"
}

Response samples

Content type
application/json
Example
{
  • "approved": true,
  • "available": true
}

Soft-delete a user account

Sets users.status='deleted', is_deleted=1. Idempotent: a second call returns OK with "Hesap zaten silinmiş". Either userId or callerid is required.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
integer
callerid
string
telefon
string

Alias for callerid.

phone
string

Alias for callerid.

Responses

Request samples

Content type
application/json
Example
{
  • "callerid": "905321234567"
}

Response samples

Content type
application/json
Example
{
  • "status": "OK",
  • "message": "Hesap silindi"
}

Look up a country dial code (e.g. TR → +90)

Returns { countryCode, dialCode } from a hardcoded ISO-2 → dial-code map. Unknown codes default to +90.

Authorizations:
BearerAuth
Request Body schema: application/json
required
countryCode
string

ISO-2 (e.g. TR, US). Default TR.

country_code
string

Alias.

Responses

Request samples

Content type
application/json
Example
{
  • "countryCode": "US"
}

Response samples

Content type
application/json
Example
{
  • "status": "OK",
  • "data": {
    }
}

Fetch profile cards for one or more users (1:1 / discovery)

Used by the 1-on-1 discovery feature. Pass a single userId, an array userIds[] (max 50), or pool=true to get a random batch of active users (excluding the caller). Identifier accepts numeric users.id, UUID, normalized callerid, or user_key.

In pool mode, gender (female|male) narrows the batch to that gender server-side so every returned card is usable; both or an omitted value returns a mixed pool. Ignored outside pool mode.

Returns a list of profile cards: { userId, fullName, username, bio, birthDate, gender, profileAvatarUrl, rate }. Empty list (200 or 404) when nothing matches.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
string
user_id
string
Array of strings or string
user_ids
any

Alias.

pool
boolean

Fetch a random pool of active users (excludes JWT caller).

forPool
boolean

Alias.

limit
integer [ 1 .. 30 ]
Default: 12
gender
string
Enum: "female" "male" "both"

Pool mode only. female|male narrows the batch to that gender server-side; both/omitted returns a mixed pool.

Responses

Request samples

Content type
application/json
Example
{
  • "userId": "905329876543"
}

Response samples

Content type
application/json
Example
{
  • "success": true,
  • "message": "OK",
  • "data": {
    }
}

Full user profile (mobile profile screen) [DEPRECATED] Deprecated

Deprecated. Use GET /v1/users/{callerId}/profile instead. That endpoint is authenticated, mode-aware (private self-view vs public other-view), section-filterable, and includes gifts, cosmetics, wallet, and relationship. This endpoint is kept only for shipped client compatibility and will be removed in a future release.


Returns user + profile + interests in one call. Identifier: either callerId (or callerid) or userId (users.id). Computes age from birth_date. avatarUrl / profileAvatarUrl are derived via JOIN from user_profiles.avatar_id → avatars.image_url — the legacy free-text columns are gone. Both bio/about and profileAvatarUrl/avatarUrl are aliased in the response for client-version compatibility.

Uses the new error envelope ({ error: { code, message, details } }) unlike most legacy endpoints — see USER_IDENTIFIER_REQUIRED / USER_NOT_FOUND / GET_USER_PROFILE_FAILED.

Authorizations:
BearerAuth
Request Body schema: application/json
required
callerId
string
callerid
string

Alias.

userId
integer
user_id
integer

Alias.

Responses

Request samples

Content type
application/json
Example
{
  • "callerId": "905321234567"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Public profile of another user (with viewer-relative fields)

Returns the public profile shown on a user's profile page: names, viewer's private alias for the target, avatar, rating, age, bio, interests, plus viewer-relative isFriend (bidirectional friendship edge) and isSelf flags. JWT-protected; callerId is the target user's users.callerid (digits-only, verbatim — no re-normalization). The viewer is resolved from the JWT.

Authorizations:
BearerAuth
path Parameters
callerId
required
string

Target user users.callerid (digits only).

Responses

Response samples

Content type
application/json
Example
{}

Sectioned, mode-aware user profile

Returns a user's profile shaped by mode: private (self-view, when the JWT caller is the profile target) or public (other-user view).

The mode is echoed in the response envelope. Clients use it to know what they received without re-deriving it.

/me alias

Pass callerId=me (or request GET /v1/users/me/profile) to retrieve the authenticated caller's own profile. The server resolves me to the JWT callerid and sets mode=private.

Section filtering (?include=)

Sections are opt-in via a comma-separated ?include= query param. Valid section names: core, profilePicture, cosmetics, gifts, wallet, relationship.

  • Absent / empty: all sections valid for the current mode are returned.
  • core: always returned, even if omitted from include.
  • Private-only sections (wallet; private extras of profilePicture and cosmetics) are silently omitted in public mode — no error.
  • Public-only section (relationship) is silently omitted in private mode.
  • Unknown section names are silently ignored.

returnedSections in the response lists exactly which sections are present in sections.

Block gate

Before building any section the service checks the block relationship. If either the caller has blocked the target or the target has blocked the caller, the request fails with 403 PROFILE_BLOCKED and no profile body is returned.

Legacy relation

The legacy POST /getUserProfile (JWT-exempt, public, no mode concept) is deprecated and kept only for shipped client compatibility. New clients should use this endpoint.

Authorizations:
BearerAuth
path Parameters
callerId
required
string

Target user users.callerid (digits only). The special value me resolves to the authenticated caller's own callerid (mode=private).

query Parameters
include
string
Example: include=core,profilePicture,relationship

Comma-separated allowlist of section names to include. Valid values: core, profilePicture, cosmetics, gifts, wallet, relationship. Absent means all mode-valid sections.

Responses

Response samples

Content type
application/json
Example

GET /v1/users/me/profile (or a callerId equal to the caller). mode=private, so the response carries the private-only data: wallet.coinBalance, the self profilePicture.moderationState + unverifiedProfileImageUrl (pending/rejected uploads visible to the owner), and the full cosmetics.owned inventory. relationship is absent (no self-to-self relation).

{
  • "mode": "private",
  • "userId": 12345,
  • "returnedSections": [
    ],
  • "sections": {
    }
}

profile

Interest tags + first-time profile setup

List all active interest tags

Returns active rows from interests, sorted by sort_order, id.

name is localized via interest_localized_info: requested locale → eninterests.name (legacy canonical column). Supplying neither ?locale= nor Accept-Language yields English — wire shape is unchanged, so pre-localization clients keep working.

Authorizations:
BearerAuth
query Parameters
locale
string
Enum: "tr" "en"
Example: locale=tr

Overrides Accept-Language. Unknown values fall back to en.

header Parameters
Accept-Language
string
Example: tr

First segment is parsed; region tags (tr-TR) are stripped.

Responses

Response samples

Content type
application/json
Example
{}

First-time profile setup (gender + secondLanguage + interests)

Atomically upserts gender + second_language on user_profiles and replaces the user's user_profile_interests rows. Validates: gender ∈ {male, female, other}, secondLanguage non-empty. interests is optional — omit it or send [] to record "no selection"; only a non-empty list that contains no usable id is rejected.

Uses the new error envelope ({ error: { code, message, details } }).

Authorizations:
BearerAuth
Request Body schema: application/json
required
callerId
required
string
gender
required
string
Enum: "male" "female" "other"
secondLanguage
required
string
interests
Array of integers

Optional. Omitted or [] clears every selection.

Responses

Request samples

Content type
application/json
Example
{
  • "callerId": "905321234567",
  • "gender": "female",
  • "secondLanguage": "en",
  • "interests": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "Profil kaydedildi."
}

List a user's selected interest tags

name is localized the same way as /get-interests (requested locale → eninterests.name).

Authorizations:
BearerAuth
query Parameters
callerId
required
string
Example: callerId=905551112233
locale
string
Enum: "tr" "en"
Example: locale=tr

Overrides Accept-Language. Unknown values fall back to en.

header Parameters
Accept-Language
string
Example: tr

Responses

Response samples

Content type
application/json
Example
{}

Replace a user's interest tags

callerId is in the query string (not body — legacy quirk). Body is { interests: [ids] }. Atomically wipes and re-inserts the user's user_profile_interests rows. An empty list is valid and clears the selection entirely — interests are never mandatory.

Authorizations:
BearerAuth
query Parameters
callerId
required
string
Example: callerId=905551112233
Request Body schema: application/json
required
interests
required
Array of integers

[] clears every selection.

Responses

Request samples

Content type
application/json
Example
{
  • "interests": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "İlgi alanları güncellendi."
}

Update a user's secondLanguage

callerId in query, secondLanguage in body.

Authorizations:
BearerAuth
query Parameters
callerId
required
string
Example: callerId=905551112233
Request Body schema: application/json
required
secondLanguage
required
string

Responses

Request samples

Content type
application/json
Example
{
  • "secondLanguage": "en"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "İkinci dil güncellendi."
}

dashboard

Real-time voice room dashboard (no auth required). The counters are computed live from the voice_room_members / voice_room_seat_timers tables on every request.

Voice room dashboard summary (live counts)

Returns real-time summary statistics across all active rooms. The counters are computed live from the voice_room_members and voice_room_seat_timers tables on every request; no cache is involved. No auth required.

Responses

Response samples

Content type
application/json
{
  • "dashboard": {
    }
}

Voice room list with live counts

Returns all active rooms with real-time totalUserCount, listenerCount and speakerCount counts. There is no pagination (this is meant for dashboard use). No auth required.

Responses

Response samples

Content type
application/json
{
  • "generatedAt": "2026-06-16T13:00:00Z",
  • "rooms": [
    ]
}

Live game-room occupancy snapshot (monitoring)

Read-only snapshot of live game-room occupancy, built for a Grafana/Prometheus scrape. Returns rollup totals plus a per-game byGameType breakdown (a map keyed by gameType), each split into lobby (waiting) vs in-play (playing) so the dashboard can derive total players in game, total in lobby, and per-game-type counts from a single call. Every enabled catalog game is always present in byGameType — zeroed when it has no live rooms — so the breakdown never collapses to an empty list.

A player in a lobby-phase room is counted as "in lobby"; a player in a playing-phase room as "in game". Ended rooms are excluded.

No auth required — same public posture as the rest of the /fs/v2/* dashboard surface (for external monitoring tools).

Responses

Response samples

Content type
application/json
{
  • "totals": {
    },
  • "byGameType": {
    },
  • "generatedAtMs": 1750250000000
}

voice-rooms

Voice room CRUD, membership, and heartbeat

List active voice rooms

Authorizations:
BearerAuth
query Parameters
type
string
Enum: "group" "vip"
Example: type=group

Filter by room type.

page
integer >= 1
Default: 1
Example: page=1
limit
integer [ 1 .. 100 ]
Default: 20
Example: limit=20

Responses

Response samples

Content type
application/json
{
  • "rooms": [
    ],
  • "total": 0,
  • "page": 0,
  • "limit": 0
}

Create a new voice room

Authorizations:
BearerAuth
Request Body schema: application/json
required
name
string <= 255 characters

The room name chosen by the user (title is ignored when name is set).

title
string <= 255 characters

Backwards-compatible field; used when name is empty.

rtcRoomId
string

RTC (LiveKit) room name — the room value passed to the token endpoint.

zegoRoomID
string

DEPRECATED — the former name of rtcRoomId; used when rtcRoomId is empty.

type
string
Default: "group"
Enum: "group" "vip"

Room classification. Drives lobby filtering (GET /api/voice-rooms?type=...).

category
string
Default: "general"
seatCount
integer
Default: 4
isVideoEnabled
boolean
Default: false
imageUrl
string

Room image. One of the create-room image options from GET /api/voice-rooms/client-config may be sent. An empty or invalid value does not fail the request — the backend simply leaves image_url empty.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "title": "string",
  • "rtcRoomId": "string",
  • "zegoRoomID": "string",
  • "type": "group",
  • "category": "general",
  • "seatCount": 4,
  • "isVideoEnabled": false,
  • "imageUrl": "string"
}

Response samples

Content type
application/json
{
  • "id": "room_6812a3b4c5d6e7.12345678",
  • "shareCode": "kx9a2bc",
  • "rtcRoomId": "string",
  • "zegoRoomID": "string",
  • "title": "string",
  • "imageUrl": "string",
  • "listenerCount": 0,
  • "speakerCount": 0,
  • "totalUserCount": 15,
  • "topUserAvatarUrls": [
    ],
  • "topUsers": [
    ],
  • "isVideoEnabled": true,
  • "seatCount": 0,
  • "color": "#DC11FF",
  • "isFeatured": true,
  • "type": "group",
  • "category": "string",
  • "wheel": {
    },
  • "freeUsedToday": 0,
  • "freeLimit": 0,
  • "extraSitsLeft": 0,
  • "seats": [
    ],
  • "myMembership": {
    },
  • "expiresAt": "2019-08-24T14:15:22Z",
  • "coinCharged": 0,
  • "usedAllowance": true
}

Voice room client economy (env-aligned)

Literal path — must be registered before GET /api/voice-rooms/{roomID} so client-config is not parsed as a room id. Same VOICE_ROOM_* values as VoiceRoomService (takeSeat, extendSeat, VIP create).

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "serverTimeMs": 0,
  • "seatDurationSeconds": 0,
  • "freeDailyLimit": 0,
  • "seatTakeCost": 0,
  • "extendSeconds": 0,
  • "extendCoinCost": 0,
  • "vipCreateCoinCost": 0,
  • "presenceGraceSeconds": 0,
  • "heartbeatTimeoutSeconds": 0,
  • "vipHostGraceSeconds": 0
}

Get voice room detail

Returns room snapshot for the authenticated user. Includes freeUsedToday, freeLimit, and extraSitsLeft (daily sit economy, same semantics as takeSeat), plus live seats[] occupancy and caller-specific myMembership (derived from voice_room_seat_timers, not is_speaker).

The only endpoint that accepts a short shareCode in place of the canonical room_id (deep links carry the code). Resolution order is room_id first, then shareCode; the response always echoes the canonical roomId, which is what join / leave / seat / wheel calls require. A closed room is 404 ROOM_NOT_FOUND through either key — never a 5xx, never a 200 with an empty payload.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Examples:
  • room_6812a3b4c5d6e7.12345678 -
  • kx9a2bc -

Canonical room_id or short shareCode.

Responses

Response samples

Content type
application/json
{
  • "id": "room_6812a3b4c5d6e7.12345678",
  • "shareCode": "kx9a2bc",
  • "rtcRoomId": "string",
  • "zegoRoomID": "string",
  • "title": "string",
  • "imageUrl": "string",
  • "listenerCount": 0,
  • "speakerCount": 0,
  • "totalUserCount": 15,
  • "topUserAvatarUrls": [
    ],
  • "topUsers": [
    ],
  • "isVideoEnabled": true,
  • "seatCount": 0,
  • "color": "#DC11FF",
  • "isFeatured": true,
  • "type": "group",
  • "category": "string",
  • "wheel": {
    },
  • "freeUsedToday": 0,
  • "freeLimit": 0,
  • "extraSitsLeft": 0,
  • "seats": [
    ],
  • "myMembership": {
    }
}

Close a voice room (host only)

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Join a voice room

Response includes freeUsedToday, freeLimit, and extraSitsLeft so the client can show remaining daily sit allowance before calling takeSeat.

Also includes isHost (boolean): true iff the caller is the host of this room. Host concept is VIP-only — group rooms always get false. The flag is present on both fresh joins and idempotent rejoins (alreadyMember=true), so a Flutter client coming back after a force-close+resume can re-attach host-only UI (e.g. the "close room on leave" confirm dialog) without refetching room detail.

Also includes myMembership: caller-specific role/seat truth derived from live voice_room_seat_timers (not is_speaker). On rejoin with an active seat, the server releases the seat and sets forcedListenerOnRejoin=true.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
userName
string
avatarUrl
string

Responses

Request samples

Content type
application/json
{
  • "userName": "string",
  • "avatarUrl": "string"
}

Response samples

Content type
application/json
{
  • "listenerCount": 0,
  • "totalUserCount": 15,
  • "alreadyMember": true,
  • "isHost": true,
  • "freeUsedToday": 0,
  • "freeLimit": 0,
  • "extraSitsLeft": 0,
  • "myMembership": {
    }
}

Leave a voice room

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

Content type
application/json
{
  • "listenerCount": 0,
  • "totalUserCount": 14
}

Send heartbeat to keep presence active

Refreshes voice_room_members.last_seen and writes is_speaker from the request body's isSpeaker. The active seat (voice_room_seat_timers) is NOT updated by this endpoint — use POST .../seats/leave to leave a seat; isSpeaker: false alone does not vacate it.

If a member's heartbeat stops, the periodic eviction (VOICE_ROOM_HEARTBEAT_TIMEOUT_SECONDS) clears the membership and, if needed, the seat; seat_left{reason:"heartbeat_timeout"} may be broadcast.

The response carries myMembership derived from the live seat row, not from the request body's isSpeaker — use it to detect client drift.

On re-entering a room, either join or this endpoint refreshes last_seen, which effectively resets the TTL-based eviction expectation for that user (the seat still closes via seats/leave or expiry).

speaker_status_changed is broadcast over WebSocket.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
isSpeaker
boolean
Default: false

Indicates that the member is on a speaker seat (per your product definition). Sending false makes the server write voice_room_members.is_speaker = 0; it does not close the active seat timer — use seats/leave for the seat.

Responses

Request samples

Content type
application/json
{
  • "isSpeaker": false
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "myMembership": {
    }
}

Send a heart to a user in the room

Increments the target user's users.heart_count. A heart from the same sender to the same target can be sent only once (enforced by a user_heart_edges unique index → DUPLICATE_HEART on retry).

Request body target key — three accepted forms (controller tries in order): target_user_id, targetUserId, targetUserID. New clients SHOULD use targetUserID for consistency with other voice-room endpoints. The first non-empty value wins.

Broadcasts heart_sent over WebSocket with the target's new heart_count.

The response also returns the target's canonical identity (targetUserName, targetCallerId, targetUuid, targetZegoUserId) so the sender can render and broadcast the heart-like chat announcement with a real display name (never a callerId/phone) without local resolution.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
required
target_user_id
required
string

User key of the heart recipient.

Responses

Request samples

Content type
application/json
{
  • "target_user_id": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "new_heart_count": 0,
  • "targetUserName": "string",
  • "targetCallerId": "string",
  • "targetUuid": "033dde80-2475-46dc-8ff1-f20cdde41d0a",
  • "targetZegoUserId": "string"
}

voice-room-seats

Voice room seat management and timers

Take a seat in a voice room

Seat economy (all thresholds configurable via .env; defaults shown):

  1. VOICE_ROOM_FREE_DAILY_LIMIT (default 5) free sits per UTC day, tracked in voice_room_daily_sits.free_used.
  2. If exhausted, consumes one from the user's extra_sits balance.
  3. If neither, debits VOICE_ROOM_SEAT_TAKE_COST coins (default 50) via WalletService::spendCoins — idempotent on the caller's idempotencyKey or a server-derived fallback (seat_take:{roomId}:{seatIndex}:{userId}:{YYYY-MM-DD}).

The seat timer then runs for VOICE_ROOM_SEAT_DURATION_SECONDS (default 360s). The response's freeUsedToday/freeLimit/ extraSitsLeft reflect the caller's daily state after this take.

Ghost seat (server reconcile): If the slot still has an active non-expired timer but the occupant's voice_room_members.last_seen is past VOICE_ROOM_HEARTBEAT_TIMEOUT_SECONDS (same rule as stale eviction), the server atomically clears that seat (and evicts the occupant's membership when it is another user) before applying the normal take rules. Redis may emit seat_left with reason: heartbeat_timeout ahead of the usual seat_taken.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
required
seatIndex
required
integer >= 0
idempotencyKey
string

Optional client-supplied UUID. Recommended when the take will charge coins (no free/extra sits left) so retries don't double-debit. If omitted the server derives a best-effort key.

Responses

Request samples

Content type
application/json
{
  • "seatIndex": 0,
  • "idempotencyKey": "string"
}

Response samples

Content type
application/json
{
  • "expiresAt": 0,
  • "serverTimeMs": 0,
  • "seatDurationSeconds": 0,
  • "heartCount": 0,
  • "coinCharged": 0,
  • "freeUsedToday": 0,
  • "freeLimit": 0,
  • "extraSitsLeft": 0,
  • "myMembership": {
    }
}

Leave a seat in a voice room

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
required
seatIndex
required
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "seatIndex": 0
}

Response samples

Content type
application/json
{
  • "message": "Koltuk bırakıldı."
}

Extend own seat timer

Pushes the caller's seat expiry out by VOICE_ROOM_EXTEND_SECONDS (default 60s) and debits VOICE_ROOM_EXTEND_COIN_COST coins (default 0 — when zero, no wallet call is made). New expiry is max(previousExpiresAtMs, nowMs) + addedSeconds*1000, so extending a just-expired seat starts the new window from now.

Caller must be the seat occupant (NOT_YOUR_SEAT otherwise). Server broadcasts seat_timer_extended to the room.

Idempotency: if idempotencyKey is omitted, the server derives one from (roomId, seatIndex, userId, previousExpiresAt) — which means rapid double-clicks on the same seat-with-same-expiry are deduped, but different extends on the same seat are distinct.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
required
seatIndex
required
integer >= 0
idempotencyKey
string

Optional client-supplied UUID. Every extend charges coins, so supplying this lets network retries dedupe safely. If omitted the server derives a key from the current seat expiry.

Responses

Request samples

Content type
application/json
{
  • "seatIndex": 0,
  • "idempotencyKey": "string"
}

Response samples

Content type
application/json
{
  • "newExpiresAt": 0,
  • "coinCharged": 0,
  • "addedSeconds": 0,
  • "usedAllowance": true
}

Extend another user's seat timer (caller pays)

Same timer math as /seats/extend (adds VOICE_ROOM_EXTEND_SECONDS, default 60s) but the caller pays VOICE_ROOM_EXTEND_COIN_COST (default 0). The caller does NOT need to occupy a seat — only be an active room member — but targetUserID MUST currently occupy the named seatIndex (TARGET_NOT_IN_SEAT otherwise).

Server broadcasts seat_timer_extended to the room; the event's userId field refers to the seat occupant (the gift recipient), not the caller.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
required
targetUserID
required
string
seatIndex
required
integer >= 0
idempotencyKey
string

Optional client-supplied UUID. See ExtendSeatBody.

Responses

Request samples

Content type
application/json
{
  • "targetUserID": "string",
  • "seatIndex": 0,
  • "idempotencyKey": "string"
}

Response samples

Content type
application/json
{
  • "newExpiresAt": 0,
  • "coinCharged": 0,
  • "addedSeconds": 0,
  • "usedAllowance": true
}

wheel

Fortune wheel — config, rounds, bets, host enable/disable

Get wheel configuration for the room

Returns the wheel definition currently attached to this room (section count, bet mode, round duration, allowed stake amounts, and whether the host may toggle the wheel on/off).

The toggleable flag reflects room type: false for public (group) rooms where the wheel always runs while listeners are present, true for private (vip) rooms where the host drives is_enabled. Clients should use it to show or hide the enable/disable button.

Visual data (colors, labels, icons) is intentionally NOT served — the mobile client renders sections from its own asset pack keyed by (code, sectionIndex).

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

Content type
application/json
{
  • "code": "string",
  • "sectionCount": 0,
  • "betMode": "none",
  • "roundDurationMs": 0,
  • "betOptions": [
    ],
  • "sections": [
    ],
  • "toggleable": true,
  • "maxDistinctSectionsPerUserPerRound": 1,
  • "betLockMs": 30000
}

Get the room's current active round

Returns a snapshot of the in-flight round (state betting or spinning), including per-section bet totals and the caller's own bets. Returns {round: null} between rounds (idle gap).

For real-time updates use the WebSocket events wheel_round_started, wheel_round_tick, wheel_bet_placed, wheel_spin_started, wheel_round_resolved.

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

Content type
application/json
{
  • "round": {
    },
  • "nextStartAtMs": 0
}

Place a bet on the current round

Validates round window + bet options, debits coins via the wallet with idempotency key wheel_bet:{roundId}:{idempotencyKey}, then inserts the bet row. Replays of the same idempotencyKey for the same round return the prior bet without re-debiting.

Bets are accepted only while the round is in betting state and now < endsAtMs.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
required
roundId
required
integer

Active round ID returned by GET /wheel/round.

sectionIndex
required
integer >= 0

Zero-based section index in [0, wheel.sectionCount).

amount
required
integer >= 1

Stake. Must exactly match one of the values from wheel.betOptions returned by GET /wheel/config.

idempotencyKey
required
string

Client-supplied idempotency key (e.g. UUIDv4). Replays of the same key for the same round return the prior bet without re-debiting coins. Wallet-side debit is namespaced as wheel_bet:{roundId}:{idempotencyKey}.

Responses

Request samples

Content type
application/json
{
  • "roundId": 0,
  • "sectionIndex": 0,
  • "amount": 1,
  • "idempotencyKey": "string"
}

Response samples

Content type
application/json
{
  • "bet": {
    }
}

Reset (refund) all of the user's bets in the current round

Refunds every coin from the user's pending bets in the active round, marks each bet refunded, and broadcasts a wheel_bets_reset WS frame so all clients in the room re-render section totals. The originating client uses the broadcast's userId field to additionally clear its own "selected sections" UI state.

Per-bet refunds use the deterministic wallet key wheel_bet_refund:{betId}, so retries (network blip, accidental double-tap) never double-credit. Bets that the resolve path claimed (won/lost) between the user's tap and the refund loop are skipped silently — the response counters reflect what was actually refunded.

Refused while round is not in betting state OR past endsAtMs (ROUND_NOT_OPEN).

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

Content type
application/json
{
  • "reset": {
    }
}

Get a single round's detail (history)

Returns full round detail: state, timestamps, winning section, per-section bet totals, and — once state = resolved — the frozen per-section probability snapshot in basis points (weightsBp, sum = 10000). Useful for client-side audit/replay.

Authorizations:
BearerAuth
path Parameters
roomID
required
string
roundID
required
integer

Responses

Response samples

Content type
application/json
{
  • "round": {
    }
}

Attach a wheel to the room (host only)

Host-only. Attaches the wheel identified by wheelCode to this room via an idempotent upsert into voice_room_wheels (is_enabled=1 on the row). Publishes a wheel_attached bridge event so the running WebSocket worker starts ticking rounds without a restart.

Semantics per room type:

  • Public rooms (voice_rooms.type='group'): attaching IS the whole action — the wheel runs whenever listeners are present. There is no separate enable/disable toggle, and POST /wheel/disable returns 409 WHEEL_NOT_TOGGLEABLE.
  • Private rooms (voice_rooms.type='vip'): attaching also sets is_enabled=1 so the first attach starts rounds; the host can later toggle the wheel off with POST /wheel/disable and back on by calling POST /wheel/enable again (the upsert resets is_enabled=1).
Authorizations:
BearerAuth
path Parameters
roomID
required
string
Request Body schema: application/json
required
wheelCode
required
string

Wheel definition code (e.g. "classic_8"). Must exist and be active in the wheels table.

Responses

Request samples

Content type
application/json
{
  • "wheelCode": "string"
}

Response samples

Content type
application/json
{
  • "wheel": {
    }
}

Disable the room's wheel (host only, private rooms only)

Host-only, private rooms only. Sets voice_room_wheels.is_enabled = 0 and publishes a wheel_disabled bridge event so the WebSocket worker stops ticking new rounds for this room.

This endpoint is not valid for public rooms (voice_rooms.type='group'): their wheel is always on while listeners are present and the call returns 409 WHEEL_NOT_TOGGLEABLE. Use the toggleable flag from GET /wheel/config to decide whether to show the disable button.

NOTE: An in-flight round is NOT auto-cancelled by this call; its DB row keeps its current state. The WS worker simply stops advancing it. Operator-level cleanup is required for stuck rounds.

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

Content type
application/json
{
  • "ok": true
}

Global cross-room wheel winners leaderboard

Returns the top room-wheel winners across all rooms for a rolling time window. Ranking metric is gross winnings: SUM(payout_amount) over the user's resolved bets (won or lost) inside the window. Refunded and pending bets are excluded. Only users with positive winnings are returned.

There is no net-profit figure on this board. A user's losses are ignored entirely — they never exclude the user, move their rank, or reduce the amount reported for them — so no entry can carry a negative amount. profit is a deprecated alias of totalPayout carrying the same winnings value.

Window is keyed off wheel_bets.resolved_at_ms (set when the round resolves), so a bet placed inside the window but resolved outside it does not count, and vice versa.

Display name + avatar come from the user's most recent voice_room_members row (the in-room snapshot taken at join). Users who have never joined a voice room appear with empty userName/avatarUrl. Each entry also carries activeFrame (live-resolved active cosmetic frame, or null — ADR 2026-07-07-active-frame-on-avatar-surfaces).

Windows are calendar-aligned in Europe/Istanbul (the user-perceived local day) — they reset at local midnight, not at UTC midnight:

  • daily - since today 00:00 local; resets tomorrow 00:00 local.
  • weekly - since Monday 00:00 local (ISO week); resets next Monday 00:00 local.
  • monthly - since the 1st of the current month 00:00 local; resets on the 1st of next month 00:00 local.

resetAtMs is the exact moment the current bucket flips to a new one — clients can use it to render a countdown.

Authorizations:
BearerAuth
query Parameters
period
string
Default: "daily"
Enum: "daily" "weekly" "monthly"

Rolling time window. Defaults to daily.

limit
integer [ 1 .. 100 ]
Default: 50

Max entries returned. Clamped to [1, 100].

Responses

Response samples

Content type
application/json
{
  • "period": "daily",
  • "sinceMs": 0,
  • "untilMs": 0,
  • "resetAtMs": 0,
  • "leaderboard": [
    ]
}

wheel-daily

Daily fortune wheel — single-player spin, no room, no WebSocket

Get the active daily wheel + the caller's remaining spins

Resolves the active daily wheel via app_config.daily_wheel_code and returns:

  • Structural config — sectionCount, dailyLimit.
  • The per-section catalog (sections[]) — sectionIndex + reward descriptor for every section, so the client can render the wheel without a second round-trip.
  • Per-user state — remainingToday spins for the current server date (Y-m-d).
  • Reset timing — resetAt and serverTime (formatted UTC datetime strings, DD:MM:YYYY HH:mm:SS:MS) so the client can run a correct, locally-rendered countdown to the next daily reset. The server does not assume a client timezone.

Visual data (colors, labels, icons) is intentionally NOT served — the mobile client renders sections from its own asset pack keyed by (wheelCode, sectionIndex).

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "wheelId": 0,
  • "wheelCode": "string",
  • "sectionCount": 0,
  • "dailyLimit": 10,
  • "remainingToday": 0,
  • "extraSpinsAvailable": 0,
  • "sections": [
    ],
  • "resetAt": "17:04:2026 00:00:00:000",
  • "serverTime": "16:04:2026 14:23:12:456"
}

Spend one daily spin and receive the reward

Atomic single-player spin. Checks the caller's remaining spins for today, increments the counter, creates a scope='daily' round with a frozen per-section probability snapshot, draws a winner via HMAC(seed, roundId), and grants the reward directly:

  • coin_flat section → coins credited via the wallet.
  • item section → item recorded on the round; stake refund does not apply (daily spins have no stake).

Idempotency is mandatory. Retries with the same idempotencyKey replay the original round result (replayed: true) without consuming another daily spin or re-granting the reward. The index (wheel_id, user_id, idempotency_key) enforces this uniqueness server-side.

Authorizations:
BearerAuth
Request Body schema: application/json
required
idempotencyKey
required
string

Client-supplied idempotency key (e.g. UUIDv4). Mandatory. Retries with the same key replay the original spin result without consuming another daily try or re-granting the reward. The unique index on (wheel_id, user_id, idempotency_key) enforces this server-side.

Responses

Request samples

Content type
application/json
{
  • "idempotencyKey": "string"
}

Response samples

Content type
application/json
{
  • "roundId": 0,
  • "winningSectionIndex": 0,
  • "rewardKind": "coin",
  • "amount": 0,
  • "itemRef": "string",
  • "itemQty": 0,
  • "itemDisplayNameTr": "string",
  • "remainingToday": 0,
  • "extraSpinsAvailable": 0,
  • "replayed": true
}

Get the caller's past daily spins

Returns the caller's daily-wheel rounds for the active wheel, most-recent first. Each entry includes the winning section and a reconstructed reward descriptor derived from the current section config (not from an independently stored payout row — daily spins have no wheel_bets record).

Authorizations:
BearerAuth
query Parameters
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30

Page size (clamped to [1, 200]).

Responses

Response samples

Content type
application/json
{
  • "rounds": [
    ]
}

Get the next daily-spin reset datetime (UTC)

Returns the next daily-spin reset as a formatted DD:MM:YYYY HH:mm:SS:MS string in UTC, plus the current server clock in the same format, so the client can render a drift-corrected, locally-formatted countdown. The server is timezone-agnostic — localization is the client's responsibility.

The same data is also included in /config; this endpoint exists for clients that only need the timing (e.g. a background timer) without fetching the whole wheel config.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "resetAt": "17:04:2026 00:00:00:000",
  • "serverTime": "16:04:2026 14:23:12:456"
}

games

Multiplayer-game ledger surface. Player-facing /api/games/{gameType}/join debits the entry fee from the JWT-bound user. Backend is the only authoritative source on amounts — wire carries gameType, never coin amounts. Catalog lives in the games table. The game-server-facing settle/payout endpoint lives in the separate Game-Server API doc under its own X-Game-Server-Key auth.

Pay the entry fee for a game and join it

Player-facing entry-fee debit. JWT-auth — the caller is the player themselves; their callerId is taken from the JWT, NEVER from the request body.

Backend looks up entry_cost for gameType in the games catalog and debits the user's wallet via WalletService::spendCoins (atomic, ledgered, RC-mirrored, idempotent on idempotencyKey).

The request carries no amount: the backend is the only authoritative source on coin movement amounts.

Idempotent on (gameType, idempotencyKey). Re-sending the same key returns the original outcome with replayed: true and does not double-debit.

The optional app_page body field controls whether a debit actually happens. When omitted/empty, the call is a verification only: catalog, caller, AND balance are validated (so an under-funded user gets the same 402 INSUFFICIENT_COINS they'd get from the real debit), but no coin moves and no ledger row is written. When set, the entry fee is actually debited as described above. The home-page game tile uses verification mode; the game's own start page sends app_page to perform the real debit on Play.

Authorizations:
BearerAuth
path Parameters
gameType
required
string^[a-z0-9_-]{1,32}$
Example: ludo

Game type key (catalog games.game_type). Lowercase, [a-z0-9_-]{1,32}.

Request Body schema: application/json
required
idempotencyKey
required
string [ 1 .. 128 ] characters

Client-supplied stable identifier for this join attempt. Must be unique per logical join. Re-sending the same key returns the original outcome with replayed: true (no double-debit).

app_page
string <= 64 characters

Optional client-supplied origin tag for this join. When omitted (or empty), the call is treated as a verification only — catalog, caller, AND balance are validated (so an under-funded user gets 402 INSUFFICIENT_COINS just like the real debit would return), but no coin is debited and no ledger row is written. When non-empty, the entry fee is debited as usual. Use case: the home-page game tile sends no app_page to verify availability, while the game's own start page sends its page identifier (e.g. "ludo_start") to perform the actual debit on Play.

Responses

Request samples

Content type
application/json
{
  • "idempotencyKey": "a3f1c0b2-9d2e-4f1a-b3c4-5e6f7a8b9c0d",
  • "app_page": "ludo_start"
}

Response samples

Content type
application/json
{
  • "gameType": "ludo",
  • "entryCost": 100,
  • "ledgerId": 12345,
  • "balanceAfter": 400,
  • "referenceId": "game_entry:ludo:a3f1c0b2-...",
  • "replayed": true
}

Read the caller's own result for a settled game round

Player-facing settle-result read. JWT-auth — the caller only ever sees their own roster row; other players' payouts are never exposed.

The Flutter client learns "round ended" locally from the SUD SDK (mg_common_game_settle via onGameStateChange) and then calls this endpoint to learn the server-authoritative coin payout. SUD cannot carry server-computed amounts to clients, hence this surface.

roundId is the SUD game_round_id — the same value the game server sends to /api/game-server/games/settle as idempotencyKey (stored in game_match_history.idempotency_key).

Returns {"status": "pending"} until the SUD → game-server → settle pipeline lands. Unknown roundIds are deliberately also pending (the client retries briefly, then gives up). Read-only, no side effects.

Authorizations:
BearerAuth
path Parameters
gameType
required
string^[a-z0-9_-]{1,32}$
Example: okey

Game type key (catalog games.game_type). Lowercase, [a-z0-9_-]{1,32}.

roundId
required
string^[A-Za-z0-9_.:\-]{1,128}$
Example: sud-r-abc123

SUD game_round_id, as delivered to the client in mg_common_game_settle.

Responses

Response samples

Content type
application/json
{
  • "status": "pending",
  • "didWin": true,
  • "amountWon": 180,
  • "entryCost": 100,
  • "score": 0,
  • "balance": 280,
  • "settledAtMs": 0
}

Allocate (or join) a game room for the caller

Player-facing room allocation (pull path). JWT-auth — the caller is the player; their callerId comes from the JWT, never the body. Returns a roomId the client hands to the game SDK (e.g. SUD loadGame).

The backend finds an open lobby of this game with a free seat and joins the caller into it; if none exists (or the chosen one is full) it mints a brand-new room. This is what makes multi-lobby and friend-invitation work. No coins move here — entry is debited only when the match starts.

Optional targetRoomId asks to join a specific room (friend-invite); it is honored only if that room is still an open, non-full lobby, otherwise normal find-open-or-create applies.

Only games with a declared player_size (seat count) can be allocated here; others return 409 ROOM_ALLOC_UNSUPPORTED (their gameserver owns allocation and registers rooms via the game-server room endpoints).

Authorizations:
BearerAuth
path Parameters
gameType
required
string^[a-z0-9_-]{1,32}$
Example: okey

Game type key (catalog games.game_type). Lowercase, [a-z0-9_-]{1,32}.

Request Body schema: application/json
optional
targetRoomId
string <= 64 characters

Friend-invite hook: ask to join a specific room. Honored only if that room is still an open, non-full lobby; otherwise normal find-open-or-create applies.

Responses

Request samples

Content type
application/json
{
  • "targetRoomId": "okey-7f3a9c2b1e4d5a6f"
}

Response samples

Content type
application/json
{
  • "roomId": "okey-7f3a9c2b1e4d5a6f",
  • "capacity": 4,
  • "currentMembers": 2,
  • "reused": true
}

Leave the caller's current game room

Player-facing room leave (pull path). JWT-auth — the caller is the player; their callerId comes from the JWT, never the body. The Flutter client overlays its own exit button on every game (SUD- and gameserver-based alike) and calls this when the player taps it.

The backend marks the caller left in the room and, if no active members remain, closes the room. The leave reason is always left server-side: a client can never assert match_ended (only the gameserver may, right after a clean settle). No coins move here.

Idempotent and fail-soft: leaving a room that is already closed or never existed returns 200 with closed: false and currentMembers: 0, so the exit overlay always succeeds.

Authorizations:
BearerAuth
path Parameters
gameType
required
string^[a-z0-9_-]{1,32}$
Example: okey

Game type key (catalog games.game_type). Lowercase, [a-z0-9_-]{1,32}.

Request Body schema: application/json
required
roomId
required
string <= 64 characters

The room the caller is leaving — the same roomId returned by the allocate endpoint and handed to the game SDK.

Responses

Request samples

Content type
application/json
{
  • "roomId": "okey-7f3a9c2b1e4d5a6f"
}

Response samples

Content type
application/json
{
  • "roomId": "okey-7f3a9c2b1e4d5a6f",
  • "closed": true,
  • "currentMembers": 1
}

game-invitations

Friend-to-friend game invitations with a configurable eligibility window (system_config.game_invite_ttl_seconds, default 15 min).

Two issuers write the same state machine: the gameserver orders room-scoped invites over X-Game-Server-Key (see the Game-Server API doc), and the client orders game-scoped ones here. The invitee reads and answers on this surface.

An invitation is a hint, not a seat reservation — this backend never calls a gameserver, so nothing is held and the invitee races for a seat like anyone else. Accepting performs a balance preflight but moves no coins; entry is debited at match start as always.

See docs/systems/game-invitations.md.

Invite a friend to a game (game-scoped)

"Come play X with me" — sent from anywhere in the app, with no lobby involved, so the invitation carries no roomId.

The inviter is the JWT user; a client can only ever invite as itself. Invitations into a specific lobby are ordered by the gameserver instead (see the Game-Server API), because only it knows the room.

Re-inviting the same friend to the same game refreshes the existing pending invitation — new window, re-pushed — and returns the same invitationId. There is no second row and no idempotency key.

Guards, all server-side: mutual friendship, ban and no_game_invite restriction on both parties, a per-inviter rate limit, and a cap on simultaneously pending invitations.

See docs/systems/game-invitations.md.

Authorizations:
BearerAuth
Request Body schema: application/json
required
gameType
required
string^[a-z0-9_-]{1,32}$

Catalog key (games.game_type). Must be an enabled game.

inviteeCallerId
required
string

The friend being invited, by users.callerid. The inviter is always taken from the JWT and never read from the body.

Responses

Request samples

Content type
application/json
{
  • "gameType": "okey",
  • "inviteeCallerId": "905322824782"
}

Response samples

Content type
application/json
{
  • "invitationId": 1042,
  • "expiresAtMs": 1769500800000,
  • "status": "pending"
}

List this user's live game invitations

Both directions, live rows only — anything expired, answered, or voided is already gone. Poll on app resume and when the games surface opens; there is no socket for this.

Reading stamps seen_at_ms on the incoming rows (the "surfaced" funnel signal). Outgoing rows are never stamped.

Each row carries expiresAtMs for the countdown. When it elapses, drop the row locally — the server will refuse the accept with INVITATION_EXPIRED regardless.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "incoming": [
    ],
  • "outgoing": [
    ]
}

Accept an invitation and get the launch payload

Invitee only. On success the invitation moves to accepted and the response tells the client what to launch.

No coins move. The entry fee is checked against the balance as a preflight and debited only at match start, as it always has been.

The room check is advisory: our room registry is fed fire-and-forget by the gameservers and can be stale, so a 409 means we positively know the room is dead or full — but a 200 is not a guarantee of a seat. No seat is reserved. If the gameserver refuses the join, drop the player at that game's lobby and call join-failed.

accepted is not joined: the invitation is only marked joined when the gameserver reports the real join.

Authorizations:
BearerAuth
path Parameters
id
required
integer <int64>
Example: 1042

game_invitations.id.

Responses

Response samples

Content type
application/json
{
  • "gameType": "okey",
  • "roomId": "room_2_lxy",
  • "entryCost": 500
}

Decline an invitation

Invitee only. Terminal — but it frees the inviter to send a fresh invitation for the same game.

Authorizations:
BearerAuth
path Parameters
id
required
integer <int64>
Example: 1042

game_invitations.id.

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Withdraw an invitation you sent

Inviter only. Mirror of decline.

Authorizations:
BearerAuth
path Parameters
id
required
integer <int64>
Example: 1042

game_invitations.id.

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Report that the seat was gone after accepting

Call this when the client accepted but the gameserver then refused the seat (room_full, game_started, room_closed).

The invitation stays accepted — the player really did accept, and rewriting that would erase the very thing this records. Only the reason is stored. The resulting accepted-but-never-joined rate is the metric that decides whether seat reservations are ever worth building.

Fire-and-forget from the client's point of view.

Authorizations:
BearerAuth
path Parameters
id
required
integer <int64>
Example: 1042

game_invitations.id.

Request Body schema: application/json
optional
reason
string
Enum: "room_full" "game_started" "room_closed" "unknown"

The gameserver rejection code. Anything else is stored as unknown.

Responses

Request samples

Content type
application/json
{
  • "reason": "room_full"
}

Response samples

Content type
application/json
{
  • "error": {
    }
}

payment

Purchases, RevenueCat webhook, package catalog, IVR-recovery, and server-initiated coin spends. Coin economy SSOT lives in .claude/plans/wallet-system.md.

List a user's purchase history

Returns rows from user_purchases for the supplied userId. Used by the mobile client's purchase-history screen.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
required
integer

Responses

Request samples

Content type
application/json
{
  • "userId": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Record a non-RC payment outcome (legacy IVR flow)

Inserts a row into paymentHistory. Whitelisted body fields: salesChannel, phoneNumber, productCode, transactionDate, purchaseID, ipAdress, durationMinutes, status. Used by the legacy phone/IVR billing flow — RevenueCat purchases use /revenueCatPaymentWebhook.

Authorizations:
BearerAuth
Request Body schema: application/json
required
salesChannel
string
phoneNumber
string
productCode
string
transactionDate
string
purchaseID
string
ipAdress
string
durationMinutes
integer
status
string
property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "salesChannel": "string",
  • "phoneNumber": "string",
  • "productCode": "string",
  • "transactionDate": "string",
  • "purchaseID": "string",
  • "ipAdress": "string",
  • "durationMinutes": 0,
  • "status": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

RevenueCat webhook (coin grants + VIP activations)

Public endpoint called by RevenueCat. Payload shape per RC docs.

Behavior:

  • Validates and dedupes by event.id against revenuecat_webhook_events (idempotent on retries).
  • For VIRTUAL_CURRENCY_TRANSACTION (or coin_* product): credits coins via wallet_transactions + user_wallet_summary. Hardcoded coin map: coin_1=10000, coin_2=70000, coin_3=150000.
  • For VIP products (vippackage1=1, vippackage2=7, vippackage3=30): inserts user_purchases for the audit ledger, then pushes the subscription to IVR via POST /ivr/subscriptionsAdd. To preserve renewal-while-active stacking, the handler first queries IVR for the user's current expiry and offsets the new subscription's subscriptionsStart accordingly. There is no local VIP table — IVR is authoritative.
  • VIP lifecycle events (EXPIRATION, BILLING_ISSUE, SUBSCRIPTION_PAUSED, UNCANCELLATION, CANCELLATION) are observability-only — logged and acked, no DB writes. IVR independently expires its own subscriptions based on the durationMinutes it received at subscriptionsAdd.
  • Anonymous ($RCAnonymousID:*) and unknown product events are marked processed and ignored.

No JWT — public route. Future hardening: signature verification via REVENUECAT_WEBHOOK_SECRET (planned in wallet-system.md).

Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

List in-app purchase packages

Returns the static packages_map from config/payment_packs.php. Mobile client uses this for the shop / paywall display alongside the live price catalog from RevenueCat.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

HTML payment report (admin/internal)

Renders an HTML page with monthly counts of distinct paying phones, broken down by salesChannel (Google vs Apple). Accepts year and month either as query params (GET) or form body (POST). Returns text/html — not JSON.

No JWT in current implementation; treat as internal-only.

Authorizations:
BearerAuth
query Parameters
year
string
Example: year=2026

4-digit year (defaults to current).

month
string
Example: month=5

1- or 2-digit month (defaults to current).

Responses

Same as GET, with year/month in form body

Authorizations:
BearerAuth
Request Body schema: application/x-www-form-urlencoded
year
string
month
string

Responses

Retry recently-failed PBX payment subscriptions

Reads up to 10 paymentHistory rows with status='Pbx System ERROR' from the last hour and retryCount < 3, replays them against the configured IVR subscriptionsAdd endpoint, then writes back the outcome (OK-LastError on success, retryCount++ and lastRetryError on failure). Returns success/fail counts.

Operationally invoked from a scheduled job — no auth in current implementation; lock down at the network layer.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

wallet

Coin balance, transactions, and grants (new-format)

Get current coin balance

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
Example
{
  • "status": "OK",
  • "balance": 4820
}

List coin history entries for the authenticated user

Paginated, newest-first feed of coin_history rows for the authenticated user. Reads coin_history alone — the unified ledger carries the full picture via metadata.source / metadata.purpose. (The historical joins to coin_spend_transactions and coin_products are gone; action_type / resource_* / price_label remain in the shape for compatibility but are null on new rows.)

Authorizations:
BearerAuth
query Parameters
page
integer >= 1
Default: 1
Example: page=1

1-indexed page number.

limit
integer [ 1 .. 100 ]
Default: 20
Example: limit=20

Rows per page.

type
string
Default: "all"
Enum: "all" "purchase" "spend" "refund" "grant" "migration"
Example: type=spend

Filter by coin_history.type. all disables filtering. Unrecognized values silently fall back to all.

Responses

Response samples

Content type
application/json
Example
{
  • "status": "OK",
  • "data": {
    }
}

coins

Direct user-to-user coin transfers. A percentage fee is burned on every move, so the recipient is credited netAmount, not amount. Recipients are addressed by callerId, never by users.id. Ships dark behind coin_transfers_enabled; /config is the only read that answers while the switch is off.

Send coins directly to another user

Moves coins from the caller's wallet to another user's wallet in one atomic MySQL transaction, burning a configurable percentage fee.

Addressing is open — any active human account may be addressed by callerId. There is no friendship requirement and no sender gate. Two system_config levers (coin_transfer_require_purchase, coin_transfer_min_account_age_days) exist but ship disabled; when an operator enables one, a blocked sender gets 403 SENDER_NOT_ELIGIBLE.

The fee is server-computed. Never derive it client-side — call GET /v1/coins/transfer/quote for a display value. The server recomputes at execution time regardless of what the client believes.

Idempotent. idempotencyKey is namespaced per sender. A replay returns 200 with the original result and replayed: true; a fresh transfer returns 201.

Caps are enforced over a rolling 24-hour window, not a calendar day. A breach returns 409 CAP_EXCEEDED with details.retryAfterMs.

Requires coin_transfers_enabled; otherwise 503.

Authorizations:
BearerAuth
Request Body schema: application/json
required
recipientCallerId
required
string

Recipient's users.callerid (digits only, stored verbatim).

amount
required
integer >= 1

Gross coins the sender pays. Must sit within coin_transfer_min_amount and coin_transfer_max_amount. The recipient receives amount - feeCoins.

idempotencyKey
required
string <= 120 characters

Client-generated key, namespaced server-side per sender. Replaying it returns the original transfer instead of moving coins twice.

note
string or null <= 140 characters

Optional sender message. Control characters are stripped; an over-long note is rejected with INVALID_NOTE. Never logged.

Responses

Request samples

Content type
application/json
{
  • "recipientCallerId": "905551112233",
  • "amount": 500,
  • "idempotencyKey": "9f1c0f0e-2f7a-4a1e-9a1d-2c4a1b6f0e21",
  • "note": "iyi oyundu"
}

Response samples

Content type
application/json
{
  • "transferId": 1042,
  • "recipientCallerId": "905551112233",
  • "recipientDisplayName": "Ayşe",
  • "amount": 500,
  • "feeCoins": 25,
  • "netAmount": 475,
  • "senderBalanceAfter": 12500,
  • "caps": {
    },
  • "replayed": false,
  • "createdAtMs": 1754300000000
}

Send-coins screen configuration (fee rate, bounds, caps)

Everything a client needs to render the send-coins screen in one call: whether the feature is on, the fee percentage and its floor/ceiling, the amount bounds, the note length limit, and the caller's own remaining 24h allowances.

This endpoint answers 200 even while the kill switch is off, with enabled: false. It is the one coin-transfer read that does not return 503 TRANSFERS_DISABLED — a client that got a 503 here could not tell "the feature is switched off, hide the entry point" from "the backend is broken, show an error".

The fee remains authoritative only on the server. Use this to explain the fee ("%5 komisyon"), GET /v1/coins/transfer/quote to preview a specific amount, and neither to compute the charge — execution recomputes regardless.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "enabled": true,
  • "fee": {
    },
  • "limits": {
    },
  • "caps": {
    }
}

Preview the fee, net amount and remaining caps for a transfer

Advisory preview so the client can show "they receive N" without ever implementing the fee formula. Moves nothing. The server recomputes the fee at execution time, so a quote is never binding.

Authorizations:
BearerAuth
query Parameters
amount
required
integer >= 1
Example: amount=500

Gross coins the sender would pay.

Responses

Response samples

Content type
application/json
{
  • "amount": 500,
  • "feeCoins": 25,
  • "netAmount": 475,
  • "caps": {
    }
}

The caller's own transfer receipts

Keyset-paginated on the single id column, newest first. This is the only user-facing record of a coin movement — GET /getCoinBalance returns a scalar and there is no other coin history endpoint.

Authorizations:
BearerAuth
query Parameters
direction
string
Default: "all"
Enum: "sent" "received" "all"
limit
integer [ 1 .. 50 ]
Default: 20
cursor
integer

id of the last row from the previous page. Omit for the first page.

Responses

Response samples

Content type
application/json
{
  • "transfers": [
    ],
  • "hasMore": true,
  • "cursor": 1042
}

vip

VIP status. As of 2026-04-29 every endpoint reads live from the IVR service (${service_base_url}/ivr/...), the cross-channel system of record. There is intentionally no local VIP cache table. GET /vip/me is the canonical read. /checkVip, /vipQuery, /subscriptionsGet, /deleteVip are kept for Flutter backwards-compat and now route through the same IVR client.

Raw IVR subscription lookup by phone (legacy)

Legacy passthrough that POSTs { callerId: <phone> } to the upstream IVR subscriptionsGet endpoint and returns the response verbatim. The response shape is IVR's, not ours — see /subscriptionsGet for the full field list.

Kept for Flutter diagnostic-screen backwards-compat. New callers should use GET /vip/me (JWT-self) — that endpoint returns a normalized envelope and correctly handles the IVR quirk where uuid stays populated after the subscription expires.

Authorizations:
BearerAuth
Request Body schema: application/json
required
phone
required
string

Phone number; non-digits are stripped server-side.

Responses

Request samples

Content type
application/json
{
  • "phone": "string"
}

Response samples

Content type
application/json
{ }

Cancel a user's active IVR subscription

Resolves the user's active subscription on IVR (POST /ivr/subscriptionsGet), then cancels it (POST /ivr/subscriptionsDelete with the resolved uuid). Returns { status: "OK" } on successful cancellation.

Pre-2026-04-29 this endpoint wrote to a now-defunct local vipUsers table — a silent no-op. The current implementation actually performs the cancellation against IVR.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
required
string

Caller-id-format phone (digits only; non-digits stripped server-side). Field name is legacy — accepts callerid and phone as synonyms.

Responses

Request samples

Content type
application/json
{
  • "userId": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Boolean VIP check (legacy)

Returns { vip: true|false|null, vipStatus, source, stale } based on whether the caller has an active subscription on IVR. Active means BOTH the IVR record's uuid is set AND subscriptionsEnd is in the future — IVR retains the latest subscription's uuid even after it expires, so uuid-presence alone is not a reliable signal.

Routes through VipSummaryService (Redis read-through cache fronting IVR). On IVR outage the last cached envelope is served (source: 'cache', stale: true). On IVR outage AND cache miss, vip is null and vipStatus is 'unknown' — clients MUST treat null as 'preserve prior state', NOT as false.

Pre-2026-04-29 this returned a raw vipUsers row and used uuid-presence as the active marker (incorrectly returning vip: true for expired records). Pre-2026-05-11 it surfaced IVR transport errors as 503 which the Flutter client silently treated as vip: false, causing intermittent crown-swing for actual VIP users. Both behaviors are now fixed.

Authorizations:
BearerAuth
Request Body schema: application/json
required
phone
string

Phone number. callerid is also accepted.

callerid
string

Responses

Request samples

Content type
application/json
{
  • "phone": "string",
  • "callerid": "string"
}

Response samples

Content type
application/json
{
  • "vip": true,
  • "vipStatus": "active",
  • "source": "live",
  • "stale": true
}

Proxy to the IVR subscriptions service

Passthrough that POSTs { callerId } to ${service_base_url}/ivr/subscriptionsGet and returns IVR's response verbatim. Used by the legacy IVR/PBX billing flow and by Flutter's VIP diagnostic screen.

IVR response fields (all strings, present even when empty): uuid, callerId, productCode, subscriptionsStart, subscriptionsEnd, subscriptionsFlag, plus a few telephony fields (genderTalk, genderDetected, malecount, femalecount) that are not relevant to subscription state.

Quirk: uuid remains populated after subscriptionsEnd passes — it's the uuid of the most-recent subscription record, not an active-marker. Use both uuid non-empty AND subscriptionsEnd > now to determine active VIP. GET /vip/me does this for you.

Authorizations:
BearerAuth
Request Body schema: application/json
required
callerid
string

Phone number. telefon and phone are also accepted.

telefon
string
phone
string

Responses

Request samples

Content type
application/json
{
  • "callerid": "string",
  • "telefon": "string",
  • "phone": "string"
}

Response samples

Content type
application/json
{ }

Get the authenticated user's VIP status

Returns the JWT user's current VIP entitlement via the VipSummaryService Redis read-through cache fronting IVR (single source of truth across telephony, mobile app, and web).

Active-VIP rule: the IVR record has a non-empty uuid AND subscriptionsEnd is strictly in the future. The legacy uuid-only check is unsafe (IVR retains the uuid after expiry).

Outcomes:

  • live — IVR responded; envelope is fresh. 200.
  • cache — IVR was unreachable; envelope served from Redis (stale: true, within TTL min(60s, time-to-expiry)). 200.
  • unknown — IVR unreachable AND no cached envelope. 503 with VIP_SERVICE_UNAVAILABLE; client should retry. Do NOT collapse this to isVip: false.
Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

ftu

First-time user onboarding. POST /ftu/trial/start activates the lifetime-once 30-minute free VIP trial that follows the welcome-wheel flow. POST /ftu/paywall/dismissed queues a 24-hour inbox offer when the user dismisses an FTU paywall without purchasing. Server-side writes use the same IVR subscription path the RevenueCat webhook uses; idempotent via user_ftu_trials.UNIQUE(user_id, trial_type) and system_messages.idempotency_key.

Grant the FTU 30-minute free VIP trial

Activates the lifetime-once 30-minute free VIP trial for the authenticated user. Triggered by the client when the user presses the "Sohbete Başla" CTA on the first-time-user onboarding overlay after spinning the welcome wheel.

Side effects (all best-effort after the IVR write commits; failures here do NOT fail the trial activation):

  • IVR subscriptionsAdd with productCode=206, durationMinutes=30 — the canonical VIP write path, shared with the RevenueCat webhook.
  • Inbox system_messages entry with title "VIP başladı", 24-hour validity, push enabled.
  • AllowanceService::grantVipUpgradeBonus — lifts the user's daily item allowances to VIP-tier amounts. Idempotent on ftu_trial:{userId}.

Idempotency. Every successful and idempotent branch returns HTTP 200 — the boolean flags in data (trialActivated, alreadyActivated, alreadyVip, skippedTrial) carry the outcome. Double-tap, retry-after-network-error, and concurrent requests all converge on the same user_ftu_trials row via a UNIQUE(user_id, trial_type) constraint.

Already-VIP behaviour. If the user already has an active paid VIP at trial time, the trial is NOT granted on top — the lifetime slot is marked skipped_paid_vip so the user cannot cycle back for a free trial after the paid sub ends.

Client contract. After a 200 response the client should push VipPostPurchaseFlowPage, which polls GET /vip/me for the actual VIP transition (max ~80 attempts × 1.5s). The client should NOT inspect the body of this response beyond knowing the call succeeded; 503 means "IVR transient; retry".

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Record FTU paywall dismissal and queue inbox offer

Called by the client when the user closes an FTU paywall screen WITHOUT completing a purchase. The backend inserts a 24-hour system_messages inbox entry (type paywall_offer) so the user can return to the offer from their inbox.

Eligible paywalls: ftu_package1 (main — VIP + 15 000 coin) and ftu_package2 (downsell — 12 500 coin).

Pre-purchase guard. If the user has already purchased the product (user_purchases or wallet_transactions credit record exists), no inbox row is created and the response carries inboxed: false, reason: "ALREADY_PURCHASED".

Idempotency. Re-calling this endpoint for the same paywallId is safe. The service uses a stable idempotency_key = ftu_paywall_inbox:{userId}:{paywallId} so the unique index on system_messages absorbs duplicate calls — the 24-hour validity window is set at first insertion and is NOT extended on re-calls.

Suppression on purchase. When the user later completes a purchase via the in-app paywall or from the inbox, the RC webhook fires suppressOnPurchase, which soft-deletes the inbox row. The next inbox fetch will not include the offer.

Client usage (fire-and-forget). The client should call this endpoint in the background when the paywall is dismissed without a purchase. Do NOT block the UI on the response; log errors only.

Inbox metadata shape (returned by GET /inbox) when metadata.type == "paywall_offer":

{
  "type": "paywall_offer",
  "paywallId": "ftu_package1",
  "productId": "ftu_package1",
  "cta": {
    "label": "Teklifi Gör",
    "deepLink": "shuffly://paywall/ftu_package1"
  }
}
Authorizations:
BearerAuth
Request Body schema: application/json
required
paywallId
required
string
Enum: "ftu_package1" "ftu_package2"

RC product id of the paywall that was dismissed.

  • ftu_package1 — main offer: VIP (1 day) + 15 000 coin.
  • ftu_package2 — downsell: 12 500 coin only.

Responses

Request samples

Content type
application/json
{
  • "paywallId": "ftu_package1"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

FTU welcome-coin grant status

Returns the caller's FTU 10 000-coin welcome-bonus status.

Use this as a fallback if the coinGrant field from POST /ftu/trial/start was not received (network error, force-quit, background kill, etc.).

Status values

status Meaning
eligible Row exists; coin not yet granted. Trigger POST /ftu/trial/start.
granted Coin was disbursed; grantedAtMs is set.
not_eligible No row (user registered before the feature, or FTU reset without re-arm). Grant will never happen.

Eligibility is established at the moment the user's auth response carries isNewUser=true (first registration or admin FTU reset → next login). Old users without a row are permanently not_eligible.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

FTU post-trial paywall cold-start decision

Returns a single shouldPresentPaywall boolean that tells the client whether to present the FTU paywall sequence on cold-start after the free VIP trial expires.

All display logic lives on the backend. The client never computes times or windows.

Decision summary

Condition shouldPresentPaywall trialStatus
No trial row false none
Trial pending / IVR failed false none
User already had paid VIP false purchased
Trial still active false active
After display window false expired
Purchase since trial start false purchased
Paywall already consumed false expired
All checks pass true expired

Client contract

  • Call on cold-start (after BottomNavbar opens, after FTU intro / daily streak).
  • shouldPresentPaywall == true → call presentFtuPaywallSequence().
  • On every paywall close (without purchase) → call POST /ftu/paywall/dismissed.
  • On error / 5xx → do NOT show the paywall (fail-closed).
  • Use trialStatus / *Ms fields for logs/analytics only.
Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
Example
{
  • "data": {
    }
}

rewards

Daily reward claims

Claim daily streak rewards (MySQL-backed)

Claims pending daily streak rewards from the user's MySQL streak state (daily_streak_users + daily_streak_config_rewards) up to and including upToDay. Coin balance lives in MySQL (user_wallet_summary + coin_history).

Per-user weekly anchor: each user's 7-day cycle resets on the weekday of their first claim. anchorDate is set then and never changes; cycleStartDate advances by whole cycleLength increments so the weekday is preserved across multi-week gaps.

Behavior (attendance ladder):

  • effectiveCap = min(upToDay, unlockedRung), where unlockedRung = min(cycleLength, attendanceCount) and attendanceCount is the number of distinct attended days in the current cycle window. A rung is unlocked only by an actual attended day (compress semantics) — calendar elapsed time alone does not unlock rungs. The call records the caller's "today" as attendance.
  • All config rewards for rungs 1..effectiveCap that lack a row in daily_streak_claims for (user_id, cycle_id, day, type) are claimed; coin amounts are credited via WalletService::grantCoins with an idempotent reference_id (daily_streak:<cycleId>:days_<...>).
  • vipMinutes rewards are recorded but not credited (feature postponed); vipAdded and newVipMinutes always return 0.
  • Cycle rotation: if DATEDIFF(today, cycleStartDate) >= cycleLength, cycleStartDate advances by floor(diff/cycleLength) * cycleLength days (preserves weekday) and a new cycleId is generated. uninterrupted_streak increments only when the just-ending cycle reached every rung AND the user returned within the next cycle window.
  • Unreached and unclaimed rewards at cycle rotation are forfeit.
Authorizations:
BearerAuth
query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

Phone-based identifier matching users.callerid. Digits only, 8-15 chars. Must match the authenticated user's JWT userId. Kept for Flutter client compatibility; will be dropped once the client stops sending it.

Request Body schema: application/json
required
upToDay
required
integer >= 1

Upper bound (inclusive) of the rung to claim. The server clamps to min(upToDay, unlockedRung) where unlockedRung = min(cycleLength, attendanceCount) and attendanceCount is the number of distinct attended days in the current cycle window. For "claim all unlocked today", pass the current unlockedRung.

Responses

Request samples

Content type
application/json
{
  • "upToDay": 4
}

Response samples

Content type
application/json
{
  • "coinAdded": 60,
  • "vipAdded": 0,
  • "claimedDays": [
    ],
  • "cycleId": "1747570800-3f9a2c14",
  • "dayIndex": 3,
  • "newCoinBalance": 5420,
  • "newVipMinutes": 0
}

Read current daily-streak status (MySQL-backed)

Returns the user's current streak cycle, attendance progress, and per-rung reward list with claim state. Lazy-creates the user's streak row on first call and rotates the cycle when elapsed. The call itself records the caller's "today" as attendance.

Attendance ladder: the claimable ceiling is unlockedRung = min(cycleLength, attendanceCount), where attendanceCount is the number of distinct days the user was active within the current cycle window (recorded passively on any authenticated request). Missing a day does not forfeit a rung — it simply does not advance progress.

Per-user weekly anchor: each user's 7-day cycle resets on the weekday of their first claim. anchorDate is set then and never changes; cycleStartDate advances by whole cycleLength increments so the weekday is preserved across multi-week gaps. At rotation, unreached and unclaimed rewards are forfeit.

The day boundary defaults to 00:00 UTC and is shifted by the daily_streak_reset_offset_minutes system config value.

claimed is tri-state:

  • true = already claimed in this cycle
  • false = unlocked-but-unclaimed (day <= unlockedRung)
  • null = locked (day > unlockedRung, not yet earned by attendance)
Authorizations:
BearerAuth
query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

Phone-based identifier matching users.callerid. Digits only, 8-15 chars. Must match the authenticated user's JWT userId.

Responses

Response samples

Content type
application/json
{
  • "cycleId": "1747570800-3f9a2c14",
  • "cycleLength": 7,
  • "anchorDate": "2026-04-09",
  • "cycleStartDate": "2026-05-14",
  • "attendanceCount": 5,
  • "unlockedRung": 5,
  • "dayIndex": 5,
  • "uninterruptedStreak": 3,
  • "rewards": [
    ]
}

quests

Time-based daily/weekly/monthly quests. GET /quests lists the active quests with the caller's progress; POST /quests/{id}/claim is the explicit reward claim. See docs/systems/quests.md.

List active quests for the authenticated user

Returns the quest definitions visible to the caller, enriched with the caller's current-period progress and claim status, plus ladder state.

Visibility depends on whether a quest is pooled or standalone:

  • Standalone (category is null) — always visible when active, in-schedule and audience-passing. Pays its own rewardPayload via POST /quests/{id}/claim.
  • Pooled (category set) — visible only when drawn into the caller's daily set. The set is drawn lazily on the first read after period rollover, one quest per category up to the tier's set size, then persisted for the rest of the day. Pooled quests pay nothing individually; coins come from the completion ladder.

Honors the quests_enabled kill-switch (returns empty list when disabled), audience filters, and the per-definition schedule window.

Period keys use the app timezone (quests_timezone system_config, default Europe/Istanbul):

  • dailyYYYY-MM-DD
  • weeklyYYYY-Www (ISO week)
  • monthlyYYYY-MM
Authorizations:
None
query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

Phone-based identifier matching users.callerid. Must match the authenticated user's JWT userId.

locale
string
Enum: "tr" "en"

Locale for title/description resolution. Falls back to the Accept-Language header, then en.

Responses

Response samples

Content type
application/json
{
  • "quests": [
    ],
  • "homepageQuests": [
    ],
  • "pinnedQuest": {
    },
  • "stats": {
    },
  • "ladder": {
    },
  • "weekly": {
    }
}

Claim a completion-ladder rung for today's quest set

Pooled quests are unpaid individually; coins come from this ladder. Completing k quests from today's assigned set makes rung k claimable. The rung list IS the daily budget — completing more quests than there are rungs yields nothing further.

Rung amounts are tier-scoped and read from system_config (quest_ladder_rungs_free, quest_ladder_rungs_vip). The tier is the one persisted on today's assignment row, so a mid-day VIP change does not alter amounts the user has already been shown.

Reward reference ID format: quest_ladder:{userId}:{periodKey}:{rungIndex}:{claimId} — unique per claim row, so an admin reset + re-claim genuinely re-grants.

Authorizations:
None
path Parameters
rung
required
integer >= 1

1-based rung index.

query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

Phone-based identifier matching users.callerid.

Responses

Response samples

Content type
application/json
{
  • "rungIndex": 1,
  • "amount": 50,
  • "tier": "free",
  • "periodKey": "2026-07-21",
  • "newCoinBalance": 12450
}

Swap one slot of today's quest set for another quest

Replaces the quest in slot with a different active, feasible quest from the same category that is not already in the set.

Rules:

  • The reroll budget is the quest_reroll allowance item (free 0, VIP 1 by default), so operators retune it in the allowance catalog and every spend is audited in user_allowance_history. It is a per-period budget across the whole set, not per slot. The tier is the one persisted on the set, so a mid-day VIP change does not grant or revoke a reroll on a set already drawn.
  • A slot whose quest is already completed cannot be rerolled.
  • The outgoing quest's progress for the period is deleted, so it starts clean if drawn again on a later day.
Authorizations:
None
path Parameters
slot
required
integer >= 0

0-based slot index within today's set.

query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

Phone-based identifier matching users.callerid.

Responses

Response samples

Content type
application/json
{
  • "slotIndex": 2,
  • "questId": 41,
  • "category": "oyun",
  • "remaining": 0
}

Claim the weekly bonus for finishing the ladder on enough days

Counts the distinct days in the current ISO week on which the caller claimed the ladder's final rung — i.e. days they actually finished the daily set. At quest_weekly_required_days (default 3) the bonus becomes claimable and pays quest_weekly_reward_amount (default 500).

Stored in quest_ladder_claims under the weekly period key with rung_index = 0, reserved for this reward — the existing unique key makes the claim idempotent.

Placeholder: the reward is expected to become status (a badge) rather than coins once the badge sub-project lands.

Authorizations:
None
query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

Phone-based identifier matching users.callerid.

Responses

Response samples

Content type
application/json
{
  • "amount": 500,
  • "periodKey": "2026-W33",
  • "newCoinBalance": 12950
}

Claim the reward for a completed quest in the current period

Atomically checks that the user has reached the quest threshold in the current period, records an idempotent claim row, then grants the reward outside the transaction.

Reward reference ID format: quest:{questId}:{userId}:{periodKey}:{claimId} (unique per claim row, so an admin reset + re-claim re-grants). Re-claiming the same quest/period returns ALREADY_CLAIMED (409).

Authorizations:
None
path Parameters
id
required
integer >= 1

Quest definition ID.

query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

Phone-based identifier matching users.callerid.

Responses

Response samples

Content type
application/json
{
  • "questId": 1,
  • "rewardType": "coins",
  • "rewardPayload": {
    },
  • "newCoinBalance": 550,
  • "periodKey": "2026-06-22"
}

leaderboards

Weekly leaderboard. Five global boards (no leagues) ranked on raw points, so the next-rank gap is a real number of points. Rewards are minted claimable and lapse if unclaimed — payout moves no coins. See docs/systems/leaderboard.md.

One weekly board, with the caller's position and next-rank gap

Global boards — there are no leagues. Ranking is on raw points, not percentile, which is what makes me.nextRankGap a real, actionable number of points rather than an unstable rank difference.

Opted-out users are omitted from top and me.neighbourhood, but they still score and are still paid; they see their own true rank on /v1/leaderboards/me.

isEstimated is true until the week reaches paid. Live standings are provisional — surface the "tahmini" label while it is set.

See docs/systems/leaderboard.md.

Authorizations:
None
query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

Phone-based identifier matching users.callerid.

board
string
Default: "composite"
Enum: "composite" "supporter" "gifter" "community" "game"

Which board. Defaults to composite (the "Star of the Week" board, shown to users as "Haftanın Yıldızı").

week
string^\d{4}-W\d{2}$
Example: week=2026-W30

ISO week key in UTC. Defaults to the current week.

limit
integer [ 1 .. 50 ]
Default: 10

Size of top. Clamped to 1–50.

Responses

Response samples

Content type
application/json
{
  • "weekId": "2026-W30",
  • "board": "composite",
  • "status": "open",
  • "startsAtMs": 0,
  • "endsAtMs": 0,
  • "poolTotal": 0,
  • "isEstimated": true,
  • "totalRanked": 0,
  • "top": [
    ],
  • "me": {
    }
}

The caller's score on every board, with the component breakdown

components carries the per-term breakdown that answers "why did my score change" — spend points, gift points, active days, bonuses, penalties. Its shape differs per board.

hidden is true for a user who opted out of public listings. Their rank here is still their real one.

Authorizations:
None
query Parameters
callerid
required
string^\d{8,15}$
week
string^\d{4}-W\d{2}$

Responses

Response samples

Content type
application/json
{
  • "weekId": "2026-W30",
  • "hidden": true,
  • "boards": {
    }
}

The scoring rules pinned to a week

A week pins its rule_version when it opens and never changes it, so these values are stable for the whole week. A newly published version takes effect from the next week only.

Multipliers come back keyed by ledger source. Note gift is a single weight for every gift; wheel_bet and gift_buy are deliberately 0 (the wheel is circulation rather than a sink, and gift stocking would double-count against the gift event's catalog value).

Authorizations:
None
query Parameters
callerid
required
string^\d{8,15}$
week
string^\d{4}-W\d{2}$

Responses

Response samples

Content type
application/json
{
  • "weekId": "2026-W30",
  • "ruleVersion": 0,
  • "pointUnitCoin": 0,
  • "actionMultipliers": {
    },
  • "compositeWeights": {
    },
  • "game": { }
}

The caller's past weeks — final rank, reward and claim status

Authorizations:
None
query Parameters
callerid
required
string^\d{8,15}$
limit
integer [ 1 .. 100 ]
Default: 20

Responses

Response samples

Content type
application/json
{
  • "items": [
    ]
}

Claim a leaderboard reward

Payout mints no coins — settling a week writes claimable reward rows, and this endpoint is what actually credits them. An unclaimed reward lapses after leaderboard_reward_claim_days (default 30) and its value returns to the next week's pool.

Idempotent: the grant is keyed on leaderboard:{weekId}:{board}:{userId}, so a replay returns replayed: true and credits nothing further.

Authorizations:
None
path Parameters
id
required
integer <int64>

leaderboard_reward.id.

query Parameters
callerid
required
string^\d{8,15}$

Responses

Response samples

Content type
application/json
{
  • "rewardId": 0,
  • "amount": 0,
  • "replayed": true
}

The caller's leaderboard rewards in every state

Includes claimable, claimed and lapsed. Surface expiresAtMs on claimable rows — the reward is lost if it is not claimed in time.

Authorizations:
None
query Parameters
callerid
required
string^\d{8,15}$

Responses

Response samples

Content type
application/json
{
  • "items": [
    ]
}

allowances

Per-user usage-right counters (free seat extends, free gift, etc.)

List all allowance balances for the authenticated user

Returns one entry per active catalog item (allowance_items with is_active=1). Ensures a user_allowances row exists per item, applies lazy expiry under lock, and resolves the user's tier via VipService. Items the tier treats as unlimited (isEnforced=false) are returned with balance: null and unlimited: true.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "resetAt": "22:04:2026 00:00:00:000",
  • "serverTime": "21:04:2026 18:42:17:013"
}

Get next daily-reset boundary and current server time

Identical semantics to /api/wheel/daily/reset-time. The boundary is system_config.daily_reset_hour_utc (default 0, i.e. UTC midnight).

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "resetAt": "22:04:2026 00:00:00:000",
  • "serverTime": "21:04:2026 18:42:17:013"
}

Get a single allowance balance by item key

Authorizations:
BearerAuth
path Parameters
itemKey
required
string

Catalog item key (e.g. free_gift, free_seat_extend).

Responses

Response samples

Content type
application/json
{
  • "itemKey": "free_gift",
  • "displayName": "Ücretsiz hediye hakkı",
  • "balance": 1,
  • "maxCount": 1,
  • "expiryMode": "none",
  • "expiresAt": "2026-04-22 00:00:00",
  • "tier": "normal",
  • "unlimited": true
}

gifts

TikTok-style gifts — buy into inventory via the shop, send to other users (idempotent REST, auto-buy shortfall), view inventory and received collections. Real-time WS push on send (voice room, call, game room). See docs/systems/gifts.md.

List the active+listed gift catalog (in-room picker)

Returns every active (is_active=1) and listed (is_listed=1) gift ordered by sort_order. Used by the in-room gift picker. When the gifts_enabled system_config kill-switch is off, the catalog is still returned (the send endpoint is the one blocked).

Default (no ?limit): returns the complete active+listed catalog via GiftRepository::listAllListed() — no truncation. Supply ?limit (with optional ?offset) to use the paginated path (back-compat).

The full catalog also appears as the gifts[] block on GET /v1/shop.

Authorizations:
BearerAuth
query Parameters
limit
integer [ 1 .. 500 ]
Example: limit=10

When omitted, returns the entire active+listed catalog (no cap). When present, limits results and enables ?offset pagination.

offset
integer >= 0
Default: 0
Example: offset=0
locale
string
Enum: "tr" "en"
Example: locale=tr

Locale for displayName resolution. Wins over Accept-Language. Unknown values fall back to en. Also accepted via Accept-Language header.

Responses

Response samples

Content type
application/json
{
  • "gifts": [
    ]
}

Caller's owned-unsent gift inventory

Returns every gift the caller has purchased and not yet given away (user_gift_inventory, quantity > 0). Sorted by gift id ascending. Buying a gift via POST /v1/shop/items/{shopItemId}/buy increments this. Sending decrements it (auto-buy shortfall skips this table and goes straight out). Each item includes locale-resolved displayName and the full displayNames map.

Authorizations:
BearerAuth
query Parameters
locale
string
Enum: "tr" "en"
Example: locale=tr

Locale for displayName resolution. Wins over Accept-Language. Fallback: en.

Responses

Response samples

Content type
application/json
Example
{
  • "gifts": [
    ]
}

Any user's aggregate received gift collection (public profile display)

Returns the per-gift lifetime totals for a user's received collection (user_gift_received, quantity > 0), sorted by last_received_at DESC. This is the data source for the profile gift showcase — it is public and does not reveal sender identity. Each item includes locale-resolved displayName and the full displayNames map.

Authorizations:
BearerAuth
path Parameters
userId
required
integer
Example: 42

users.id of the profile to look up.

query Parameters
locale
string
Enum: "tr" "en"
Example: locale=tr

Locale for displayName resolution. Wins over Accept-Language. Fallback: en.

Responses

Response samples

Content type
application/json
Example
{
  • "gifts": [
    ]
}

Send one or more of a gift to another user

The durable gift send action. Idempotent via idempotencyKey (namespaced as gift_send:{senderId}:{clientKey} on the server).

Auto-buy shortfall logic: if the caller owns fewer gifts than quantity, the difference is auto-bought (coins debited at gift.price × shortfall). If coins are insufficient for the shortfall, the entire send fails with 402 INSUFFICIENT_COINS and nothing is given.

Real-time push (fail-open, optional): when context.type is provided and a live WS connection exists, a gift_received event is broadcast to the context (voice_room → whole room; call → both participants; game_room → relay signal to gameserver, contract-only this phase). Push never blocks or fails the send.

Kill-switch: system_config.gifts_enabled = false503 GIFTS_DISABLED.

Authorizations:
BearerAuth
Request Body schema: application/json
required
recipientUserId
required
integer

Target user users.id. Must differ from the caller (SELF_GIFT).

giftId
required
integer

gifts.id of the gift to send.

quantity
integer >= 1
Default: 1

How many to send. If the caller owns fewer in inventory, the shortfall is auto-bought (coins debited). If coins are insufficient for the shortfall, the entire send fails (402).

idempotencyKey
required
string

Client-supplied key (UUID recommended). Namespaced on the server as gift_send:{senderId}:{clientKey}. Duplicate key → 200 replay with the original result; no double charge.

object

Optional delivery context for real-time push. When supplied, the server fires a best-effort WS event to the context (voice_room → whole-room broadcast; call → both participants; game_room → relay emitted to gameserver as contract-only). Push is fail-open and never blocks the send.

Responses

Request samples

Content type
application/json
Example
{
  • "recipientUserId": 42,
  • "giftId": 7,
  • "quantity": 2,
  • "idempotencyKey": "e1b9c4a7-2f83-4d16-9a05-7c3e8b2d6f41"
}

Response samples

Content type
application/json
{
  • "transferId": 1002,
  • "giftId": 7,
  • "quantity": 3,
  • "coinsSpent": 500,
  • "fromInventory": 1,
  • "recipientUserId": 42,
  • "newInventoryQty": 0,
  • "replayed": true
}

Send any quantity of any number of different gifts to one recipient (all-or-nothing)

Batch gift send: N distinct gifts, each with a quantity, to a single recipient in one all-or-nothing call. Either the whole batch commits or nothing does — no partial charges.

Idempotent via idempotencyKey (namespaced as gift_send_batch:{senderId}:{clientKey} on the server, disjoint from the single-send gift_send: namespace).

Merge: duplicate giftIds in items are merged (quantities summed).

Caps: 1–50 distinct gifts per batch; each merged quantity 1–100000.

Cost: for each gift, any shortfall over owned inventory is auto-bought at gift.price × shortfall; the batch total is charged in one wallet debit. Insufficient coins → 402 INSUFFICIENT_COINS, nothing given.

Real-time push (fail-open): when context.type is provided, a single gift_batch_received WS event carrying an items[] array is broadcast to the context. A single aggregated FCM push is also emitted. Push never blocks the send. The recipient-facing push deep-links to the sender's profile (user_profile:<callerId>).

Kill-switch: system_config.gifts_enabled = false503 GIFTS_DISABLED.

Authorizations:
BearerAuth
Request Body schema: application/json
required
recipientUserId
required
integer

Target user users.id. Must differ from the caller (SELF_GIFT).

required
Array of objects (GiftBatchItem) [ 1 .. 50 ] items

The gifts to send. 1–50 distinct gifts; duplicate giftIds are merged (quantities summed). All-or-nothing: any invalid gift or insufficient coins rejects the whole batch.

idempotencyKey
required
string

Client-supplied key (UUID recommended). Namespaced on the server as gift_send_batch:{senderId}:{clientKey}. Duplicate key → 200 replay with the original result; no double charge.

object

Optional delivery context for real-time push. When supplied, the server fires a best-effort single gift_batch_received WS event to the context. Push is fail-open and never blocks the send.

Responses

Request samples

Content type
application/json
{
  • "recipientUserId": 42,
  • "items": [
    ],
  • "idempotencyKey": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  • "context": {
    }
}

Response samples

Content type
application/json
{
  • "batchKey": "gift_send_batch:1:f47ac10b",
  • "recipientUserId": 42,
  • "items": [
    ],
  • "totalCoinsSpent": 70,
  • "totalQuantity": 4,
  • "replayed": false
}

Send ONE gift type × N to many room members (or everyone in the room)

Group-room gift: one gift type × quantityPerRecipient, sent to a set of members of a group voice room — or, when recipientCallerIds is null or omitted, to everyone currently in the room (excluding the sender and synthetic accounts). One all-or-nothing call.

This is a different axis from send-batch (which sends many gift types to one recipient). Idempotent via idempotencyKey (namespaced gift_send_room:{senderId}:{clientKey}, disjoint from gift_send: and gift_send_batch:).

Identity: recipients are addressed by callerId (recipientCallerIds), the identity the room protocol keys members by. Explicitly-listed recipients must be live members of context.id (the roomId), else the whole send is rejected.

Caps: quantityPerRecipient 1–999; resolved recipient count 1–50.

Cost (buy-only): quantityPerRecipient × gift.price × recipientCount, charged in one wallet debit. Insufficient coins → 402 INSUFFICIENT_COINS, nothing given.

Real-time (fail-open): one gift_batch_received WS event is broadcast to the room, keyed by callerId (sender.userId + recipients[].userId are callerIds). No FCM push — recipients are present in the room.

Kill-switch: system_config.gifts_enabled = false503 GIFTS_DISABLED.

Authorizations:
BearerAuth
Request Body schema: application/json
required
giftId
required
integer

The single gift type (gifts.id) to send to every recipient.

quantityPerRecipient
required
integer [ 1 .. 999 ]

How many of the gift EACH recipient receives.

recipientCallerIds
Array of strings or null <= 50 items

CallerIds of the recipients. Null or omitted → everyone currently in the room (excluding the sender and synthetic accounts). When provided, every callerId must be a live member of context.id or the whole send is rejected (RECIPIENT_NOT_IN_ROOM).

idempotencyKey
required
string

Client-supplied key (UUID recommended). Namespaced on the server as gift_send_room:{senderId}:{clientKey}. Duplicate key → 200 replay.

required
object

The room to gift into. id (roomId) is required.

Responses

Request samples

Content type
application/json
{
  • "giftId": 7,
  • "quantityPerRecipient": 2,
  • "recipientCallerIds": [
    ],
  • "idempotencyKey": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  • "context": {
    }
}

Response samples

Content type
application/json
{
  • "roomKey": "gift_send_room:1:f47ac10b",
  • "giftId": 7,
  • "quantityPerRecipient": 2,
  • "recipientCount": 3,
  • "recipients": [
    ],
  • "totalCoinsSpent": 60,
  • "totalQuantity": 6,
  • "replayed": false
}

friends

Friend requests, friendships, blocks, and relation checks

Send a friend request

Authorizations:
BearerAuth
Request Body schema: application/json
required
targetUserId
required
string

User key of the target user.

message
string

Optional message to include with the request.

Responses

Request samples

Content type
application/json
Example
{
  • "targetUserId": "905329876543",
  • "message": "Merhaba, odada tanışmıştık."
}

Response samples

Content type
application/json
{
  • "requestId": "550e8400-e29b-41d4-a716-446655440000",
  • "dbId": 4821,
  • "targetUserId": "905329876543",
  • "status": "pending",
  • "createdAt": 1785326400000
}

List friend requests (incoming or outgoing)

Authorizations:
BearerAuth
query Parameters
direction
string
Default: "incoming"
Enum: "incoming" "outgoing"
Example: direction=incoming
status
string
Default: "pending"
Example: status=pending
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
Example
{
  • "items": [
    ],
  • "nextCursor": null,
  • "hasMore": false
}

Accept a pending friend request

Authorizations:
BearerAuth
path Parameters
fromUserId
required
string
Request Body schema: application/json
alias
string

Optional private nickname for the new friend. Persisted via the user-alias system (see /v1/aliases) — block-checked and length-limited (1–64 chars). Failure to persist the alias does NOT roll back the friendship; the alias is best-effort.

object

Optional key-value attributes for the friendship.

Responses

Request samples

Content type
application/json
Example
{
  • "alias": "Aşkım",
  • "attributes": {
    }
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "friendshipCreatedAt": 1785326400000
}

Reject a pending friend request

Authorizations:
BearerAuth
path Parameters
fromUserId
required
string

Responses

Response samples

Content type
application/json
{
  • "ok": true
}

List current user's friends

Authorizations:
BearerAuth
query Parameters
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
Example
{
  • "items": [
    ],
  • "nextCursor": null,
  • "hasMore": false
}

People you might know (friends-of-friends via BFS)

Authorizations:
BearerAuth
query Parameters
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
Example
{
  • "items": [
    ],
  • "nextCursor": null,
  • "hasMore": true
}

Permanently dismiss a recommended user

Authorizations:
BearerAuth
path Parameters
targetUserId
required
string

Responses

Response samples

Content type
application/json
{
  • "ok": true
}

Add a direct friendship (skip request flow)

Authorizations:
BearerAuth
Request Body schema: application/json
required
targetUserId
required
string
message
string

Responses

Request samples

Content type
application/json
Example
{
  • "targetUserId": "905329876543",
  • "message": "Oyundan tanışıyoruz."
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "targetUserId": "905329876543",
  • "message": "Arkadaş eklendi."
}

Remove a friend

Authorizations:
BearerAuth
path Parameters
friendUserId
required
string

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Block one or more users

Creates block rows from the caller to each userIds[] entry. Silently skips: empty strings, the caller's own ID, and IDs that don't resolve to a real user.

Friendship-aware: if the caller was already friends with the target (either via a friendships edge or an accepted friend_requests row), user_blocks.had_friendship is set to 1 so a future DELETE /v1/blocks/{id} can auto-restore the friendship. Pending friend requests between the two users are cancelled as part of the same transaction.

The response's blockedUserIds is the subset actually blocked on this call (i.e. the invalid/skipped IDs are filtered out).

Authorizations:
BearerAuth
Request Body schema: application/json
required
userIds
required
Array of strings

List of user keys to block.

Responses

Request samples

Content type
application/json
Example
{
  • "userIds": [
    ]
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "blockedUserIds": [
    ]
}

List blocked users

Authorizations:
BearerAuth
query Parameters
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
Example
{}

Unblock a user (auto-restores prior friendship)

Removes the block between the caller and blockedUserId. If the caller had blocked an existing friend (tracked via the user_blocks.had_friendship flag set at block-time), the friendship edges are recreated in both directions on unblock — no second friend-request flow is required. If no prior friendship existed, unblock only clears the block row.

This endpoint is also resilient against edge cases where exactly one direction of the friendship edge survives (e.g. from partial writes or older data) — it calls ensureBidirectionalFriendshipEdges to fix up asymmetric state.

Returns 204 No Content whether or not a block row existed (the call is idempotent).

Authorizations:
BearerAuth
path Parameters
blockedUserId
required
string

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

Check relation status with multiple users

Batch relation lookup. Each item carries the caller-perspective relation string and canSendMessages — the DM composer gate, computed by the same policy the DM send path enforces.

The DM client polls this (~20s) as the fallback for a dropped dm_relation_changed event, so canSendMessages is the field to gate the composer on; do not re-derive it from relation. See docs/systems/direct-messages.md.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userIds
required
Array of strings

List of user keys to check relation with.

Responses

Request samples

Content type
application/json
Example
{
  • "userIds": [
    ]
}

Response samples

Content type
application/json
{
  • "relations": [
    ]
}

presence

Friends online presence. Polled REST + Redis TTL: three producers (passive middleware touch, dedicated heartbeat, offline beacon) and one gated consumer endpoint that returns online status only for mutual friends of the caller. See docs/systems/presence.md.

Mark the calling user as online

Best-effort keep-alive. The Flutter client fires this every ~20s while the app is foregrounded on any screen that produces no other backend traffic. Sets presence:u:{userId} in Redis with the configured TTL. No-op (still 204) when presence_enabled is false.

Authorizations:
BearerAuth

Responses

Mark the calling user as offline immediately

Offline beacon. The Flutter client fires this on AppLifecycleState.paused and on logout. Deletes presence:u:{userId} from Redis so viewers see the friend offline within one poll cycle. Always returns 204 (best-effort, never fails).

Authorizations:
BearerAuth

Responses

Query online status for a batch of user ids

Returns online/offline status for requested user ids that are mutual friends of the caller and not blocked either way. Non-friends are silently omitted — the gating rule is the privacy guarantee, not an error condition.

The caller's allowed-friend set is cached in Redis (TTL presence_friendset_ttl_seconds, default 60s) and invalidated on friendship/block graph mutations, so steady-state polls are Redis-only with no DB hit.

Capped at presence_query_max_ids ids per call (default 200).

Authorizations:
BearerAuth
Request Body schema: application/json
required
userIds
required
Array of strings

List of user keys to query. Capped at presence_query_max_ids (default 200) — exceeding this limit returns 422 INVALID_PRESENCE_QUERY. Non-friend and blocked ids are silently omitted from the response rather than raising an error.

Responses

Request samples

Content type
application/json
Example
{
  • "userIds": [
    ]
}

Response samples

Content type
application/json
{
  • "presence": [
    ]
}

List the caller's currently-online friends, hydrated

One-call online-friends read: the caller's online friends with their display name, avatar, and active frame, so a cold client needs a single round trip instead of GET /v1/friends + POST /v1/presence/query.

Ordered most-recently-seen first and capped at friends_online_max_items (default 100); totalOnline reports the pre-cap count.

Steady state is Redis-only for the online set (cached friend-set + MGET); the database is touched once, for a bounded hydration of the online subset only.

Send the previous response's ETag back as If-None-Match to get a 304 — the hydration query is skipped entirely. The ETag covers who is online plus a friends_online_etag_bucket_seconds time bucket, not the lastSeenMs values.

presence_enabled = false, no friends, or a Redis error all return an empty list with 200 — never a 5xx.

Authorizations:
BearerAuth
header Parameters
If-None-Match
string
Example: W/"da39a3ee5e6b4b0d3255bfef95601890afd80709.28651666"

ETag from a previous response; a match returns 304.

Responses

Response samples

Content type
application/json
Example
{
  • "items": [
    ],
  • "totalOnline": 2,
  • "asOfMs": 1785326400000
}

aliases

Private user-to-user nicknames (visible only to the creator)

List the requesting user's private aliases

Returns every alias the JWT subject has set for any other user. Only the creator can read their own aliases through this surface. Aliases are not exposed to the targets they point at.

Authorizations:
BearerAuth
query Parameters
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
Example
{
  • "items": [
    ],
  • "nextCursor": null,
  • "hasMore": false
}

Fetch a single alias by target user

Authorizations:
BearerAuth
path Parameters
targetUserId
required
string

Responses

Response samples

Content type
application/json
{
  • "creatorUserId": "905321234567",
  • "targetUserId": "905329876543",
  • "alias": "Aşkım",
  • "createdAt": 1784980800000,
  • "updatedAt": 1785240000000
}

Create or update an alias for a target user

Idempotent upsert — sending the same body twice is a no-op on the second call. Block-checked symmetrically: if either party has blocked the other, the request is rejected with BLOCKED. Aliases are 1–64 characters; emojis allowed; no censor applied.

Authorizations:
BearerAuth
path Parameters
targetUserId
required
string
Request Body schema: application/json
required
alias
required
string [ 1 .. 64 ] characters

Responses

Request samples

Content type
application/json
Example
{
  • "alias": "Aşkım"
}

Response samples

Content type
application/json
{
  • "creatorUserId": "905321234567",
  • "targetUserId": "905329876543",
  • "alias": "Aşkım",
  • "createdAt": 1784980800000,
  • "updatedAt": 1785326400000
}

Remove an alias

Authorizations:
BearerAuth
path Parameters
targetUserId
required
string

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

avatars

User-facing avatar catalog, ownership, purchase, and active-avatar selection. Ownership lives in user_avatars; the active avatar is user_profiles.avatar_id.

List avatars (catalog) annotated with ownership + active flags

Returns the avatars catalog filtered to rows where is_usable=1. Rows with is_listed=0 are hidden by default but stay visible to any caller who already owns the avatar (so they can re-equip it from their inventory). Each row carries isOwned (whether the caller has a user_avatars row) and isActive (whether the avatar is the caller''s currently active avatar in user_profiles.avatar_id).

Authorizations:
BearerAuth
query Parameters
gender
string
Enum: "male" "female" "other"
Example: gender=female
tier
string
Enum: "common" "rare" "epic" "legendary"
Example: tier=rare
ownedOnly
boolean
Default: false
Example: ownedOnly=false
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 42,
  • "limit": 30,
  • "offset": 0
}

Purchase an avatar

Debits the caller's coin balance by avatars.price via WalletService::spendCoins (atomic, ledgered, idempotent on the namespaced key avatar_buy:<userId>:<avatarId>:<clientKey>), then inserts a user_avatars row with source='purchase'. Free-tier avatars (price=0) are granted directly without a wallet debit. Does NOT auto-equip — use POST /v1/me/avatar after purchase.

Authorizations:
BearerAuth
path Parameters
avatarId
required
integer >= 1
Request Body schema: application/json
required
idempotencyKey
required
string non-empty

Client-generated idempotency key (typically a UUIDv4). The server wraps this as avatar_buy:<userId>:<avatarId>:<clientKey> before calling WalletService::spendCoins, so retries deduplicate on the full namespaced key.

Responses

Request samples

Content type
application/json
{
  • "idempotencyKey": "string"
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "avatarId": 7,
  • "replayed": true,
  • "priced": true,
  • "balanceAfter": 0,
  • "ledgerId": 0,
  • "tier": "common",
  • "name": "string",
  • "displayName": "string",
  • "internalName": "string"
}

List the caller's owned avatars

All avatars the caller owns (user_avatars rows joined with avatars metadata). Flagged with source (purchase/grant/default) and isActive (matches user_profiles.avatar_id).

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 5
}

Read the caller's currently active (equipped) avatar

Returns the full avatar metadata (id, name, gender, imageUrl, tier, price, etc.) for the caller's currently active avatar (user_profiles.avatar_id). Ownership is double-checked against user_avatars — if avatar_id is NULL or points at an avatar the caller no longer owns (data-integrity drift), the endpoint returns 404 NO_ACTIVE_AVATAR rather than leaking a broken active state to the client.

When the caller has an approved uploaded profile photo, that photo's absolute CDN URL replaces imageUrl and isUploaded is true; otherwise imageUrl is the cosmetic avatar's relative path and isUploaded is false. A pending or rejected photo is never surfaced here.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "id": 7,
  • "name": "Kara Kedi",
  • "displayName": "Kara Kedi",
  • "internalName": "avatar_7",
  • "gender": "male",
  • "price": 500,
  • "tier": "common",
  • "isDefault": true,
  • "sortOrder": 10,
  • "isUsable": true,
  • "isListed": true,
  • "createdAt": "2026-04-20 12:34:56",
  • "isOwned": true,
  • "isActive": true,
  • "isUploaded": true,
  • "pictureSource": "photo",
  • "selectedProfileImageId": 4210
}

Switch the caller's active avatar

Sets user_profiles.avatar_id to avatarId. The caller must own the target avatar (have a user_avatars row). Use the buy endpoint first for any avatar not yet owned.

Authorizations:
BearerAuth
Request Body schema: application/json
required
avatarId
required
integer >= 1

Responses

Request samples

Content type
application/json
{
  • "avatarId": 1
}

Response samples

Content type
application/json
{
  • "avatarId": 7,
  • "imageUrl": "/uploads/avatars/1709_abc.webp",
  • "tier": "common",
  • "name": "string",
  • "displayName": "string",
  • "internalName": "string"
}

cosmetics

Unified cosmetics catalog — avatar frames + app backgrounds + avatars + welcome banners. Catalog with ownership/active flags, coin purchase / free claim, owned list, and per-type active selection. See docs/systems/cosmetics.md.

Global cosmetics store — every enabled item across all types, one call

The store-page endpoint. Returns ALL enabled (is_usable=1 AND is_listed=1) cosmetics grouped by type, ordered by sort_order within each type. User-agnostic — the payload carries no isOwned/isActive flags and is identical for every caller; cross-reference GET /v1/me/cosmetics for the caller's inventory and active selections. The avatar type is included like any other — filter it with ?type=avatar and ?gender=. Pagination is per type.

How background areas appear here

The keys of types are type keys, not a fixed set. Alongside the static frame / avatar / banner, every background area from the background_types catalog appears as its own key — background (the grandfathered chat area), game_background, voiceroom_background, and whatever operators add later. So background items are already separated per area, and each item additionally carries its own type field.

The items themselves are deliberately identical in shape to any other cosmetic — the same CosmeticItem object, with attrs: null. Backgrounds have no per-item extra fields; everything area-specific (orientation, ratio bounds, byte caps, whether an SVGA companion is offered) belongs to the area, not the item.

That area config is delivered by the additive top-level backgroundTypes block — the same BackgroundType objects as GET /v1/backgrounds/types, scoped to the areas present in this response (so ?type=frame returns [], and inactive areas never appear). It sits next to the existing labels and tiers blocks and follows the same locale negotiation.

Distinguishing areas from other types, in one call: a key of types is a background area iff a backgroundTypes entry has the matching type. Do not pattern-match on the key name — the chat area is called background, with no suffix.

Clients written before this block shipped are unaffected: types, labels, tiers, limit and offset are byte-for-byte unchanged, and an unknown top-level key is ignored by every existing consumer. Such clients keep resolving area config through GET /v1/backgrounds/types and joining on the key — still fully supported, just an extra round-trip.

Authorizations:
BearerAuth
query Parameters
type
string
Example: type=frame,background,game_background,avatar

Comma-separated type filter; omitted = all registered types. Accepts background-area keys (game_background, …) exactly like the static types. Unknown types → 404 UNKNOWN_COSMETIC_TYPE.

label
string
Example: label=featured,new

Comma-separated shop-label key filter, matched against each item's shop_items spine row. OR semantics — an item is kept if it carries any of the given labels (EXISTS … l.key IN (…)), and only is_active = 1 labels ever match. Keys are lower-cased, de-duplicated, and blank segments dropped before matching.

Never rejected as invalid: an unknown key simply matches nothing, so passing only unknown keys returns empty groups, not an unfiltered store. Omit the parameter to disable label filtering. The resolvable key catalog is the labels block of this same response.

tier
string
Enum: "common" "rare" "epic" "legendary"

Narrows every type to one tier. Unknown tiers → 400 INVALID_TIER.

q
string
Example: q=gold

Name substring filter.

gender
string
Enum: "male" "female" "other"

Narrows gendered types (avatar) to one gender; ignored by non-gendered types (frame/background). Invalid value → 400 INVALID_GENDER.

limit
integer [ 1 .. 200 ]
Default: 200

Per-type page size (applied within each type group).

offset
integer >= 0
Default: 0

Per-type page offset.

Responses

Response samples

Content type
application/json
{
  • "types": {
    },
  • "backgroundTypes": [
    ],
  • "labels": [
    ],
  • "limit": 200,
  • "offset": 0
}

List the cosmetics catalog of a type, annotated with ownership + active flags

Returns usable (is_usable=1) items of the type, ordered by sort_order. Rows with is_listed=0 are hidden unless the caller already owns them (avatar semantics — owners can re-equip from their inventory). Each row carries isOwned and isActive for the caller. Unknown types are rejected with 404 UNKNOWN_COSMETIC_TYPE.

This is also how you fetch background items — pass a background area key as {type} (background, game_background, …; see GET /v1/backgrounds/types for the area catalog). Items come back as ordinary CosmeticItem objects with attrs: null; area render params are not repeated here, so a client rendering one area needs the area config from /v1/backgrounds/types (or the backgroundTypes block on GET /v1/cosmetics). isActive is per area, since a user may equip one background per area simultaneously.

Note total counts usable items before the unlisted-and-unowned rows are filtered out, so it can exceed items length on the last page.

Authorizations:
BearerAuth
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

query Parameters
limit
integer [ 1 .. 200 ]
Default: 200
Example: limit=200
offset
integer >= 0
Default: 0
Example: offset=0
gender
string
Enum: "male" "female" "other"

Avatar gender filter; ignored by non-gendered types (frames, banners, every background area). Invalid value → 400 INVALID_GENDER.

label
string
Example: label=featured,new

Comma-separated shop-label key filter, same OR semantics as on GET /v1/cosmetics: an item is kept if it carries any of the given active labels. Unknown keys match nothing rather than erroring.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 42,
  • "limit": 200,
  • "offset": 0
}

Purchase (or free-claim) a cosmetic item

Debits the caller's coin balance by cosmetic_items.price via WalletService::spendCoins (atomic, ledgered, idempotent on the namespaced key cosmetic_buy:{type}:{userId}:{itemId}:{clientKey}), then inserts a user_cosmetic_items row with source='purchase'. Free items (price=0) are granted directly (source='grant') with no wallet call. Does NOT auto-equip — use POST /v1/me/cosmetics/{type} after purchase.

Authorizations:
BearerAuth
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

id
required
integer >= 1
Request Body schema: application/json
required
idempotencyKey
required
string non-empty

Client-generated idempotency key (typically a UUIDv4). The server wraps it as cosmetic_buy:{type}:{userId}:{itemId}:{clientKey} before calling WalletService::spendCoins, so retries deduplicate on the full namespaced key.

Responses

Request samples

Content type
application/json
{
  • "idempotencyKey": "string"
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "itemId": 7,
  • "type": "game_background",
  • "replayed": true,
  • "priced": true,
  • "price": 0,
  • "balance": 0,
  • "tier": "common",
  • "name": "string",
  • "displayName": "string",
  • "internalName": "string"
}

Read the caller's owned cosmetics + active selection per type (one call)

All owned items grouped by type (with source and acquiredAt) plus the active selection per type (null when nothing is equipped — no frame; app default background). Designed for app cold-start.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "owned": {
    },
  • "active": {
    }
}

Set or clear the caller's active cosmetic of a type

{"itemId": N} equips an owned, usable item; {"itemId": null} clears the slot (deletes the selection row). A request body without the itemId key is rejected with 400 INVALID_ID — a missing key is a client bug, not a clear instruction. The composite FK guarantees the selection's type always matches the item's type.

Authorizations:
BearerAuth
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

Request Body schema: application/json
required
itemId
required
integer or null >= 1

Item to equip, or null to clear the slot (deletes the selection row). The key itself is required.

Responses

Request samples

Content type
application/json
{
  • "itemId": 1
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "type": "game_background",
  • "itemId": 7
}

backgrounds

Part of the cosmetics domain, not a separate system. Backgrounds are cosmetic items like frames or banners — same cosmetic_items table, same CosmeticItem wire shape, same catalog / buy / equip endpoints under cosmetics. What makes them different is that their type is not fixed: instead of the single hardcoded background, each background area (chat, game, voice-room, profile, …) is a row in the admin-managed background_types catalog, and that row's type_key is the cosmetic type value. Areas are created at runtime with no app release.

Consequences a client must know:

  • A user has one active background per area simultaneously (not one overall), so profile reads carry a plural activeBackgrounds list — unlike the singular activeFrame / activeBanner.
  • GET /v1/cosmetics groups items by type key, so each area gets its own group; the additive backgroundTypes block in that same response says which of those group keys are areas and how to render each.
  • Background items carry no extra per-item fields — attrs is always null. Everything area-specific (orientation, ratio bounds, byte caps) lives on the area config, never on the item.

This tag holds only the area-config endpoint. Catalog, purchase, equip and ownership for background items live under cosmetics; the admin area CRUD lives under admin-background-types. See the Dynamic background types section of docs/systems/cosmetics.md.

Background-type config catalog

The entry point to the dynamic background system. Returns the render/upload config for every active background area.

What a background area is

Backgrounds are not a single fixed type — they are a dynamic, admin-managed catalog of areas (chat, game, voice-room, profile, and whatever the team adds next). Each area is its own cosmetic type value, created at runtime via the admin /admin/background-types CRUD (no app release needed). This endpoint exposes each area's config: orientation (landscape/portrait), dimension/ratio bounds, byte caps, allowed mime types, and whether an optional SVGA animation is offered. The constraints are deliberately soft hints — the client decides which area renders behind which screen; the backend only serves items dynamically, filtered by type.

How a client consumes the system

  1. GET /v1/backgrounds/types (this endpoint) → the area catalog + per-area render params.
  2. GET /v1/cosmetics/{type} with {type} = an area key → the purchasable/ownable background items for that area (the generic cosmetics catalog; ?type= also groups them on GET /v1/cosmetics).
  3. POST /v1/cosmetics/{type}/{id}/buy → buy/claim an item; POST /v1/me/cosmetics/{type} → equip one item per area (a user can have one active background per area simultaneously).
  4. Profile reads carry an additive activeBackgrounds list — every background the profile owner has equipped, each tagged with its type — so the client renders each behind the screen its area designates.

Step 2 no longer needs a client-side join for rendering: the GET /v1/cosmetics store response carries an additive backgroundTypes block with the same BackgroundType objects, scoped to the areas present in that payload. Call this endpoint when you want the area catalog on its own (e.g. at cold start, or to render an area with no purchasable items yet); read the store's block when you are already fetching items.

User-agnostic (identical for every caller); pair with GET /v1/me/cosmetics for the caller's owned + active-per-area state.

Authorizations:
BearerAuth
query Parameters
locale
string
Enum: "tr" "en"

Overrides Accept-Language for displayName resolution.

Responses

Response samples

Content type
application/json
{
  • "types": [
    ]
}

shop

User-facing shop storefront — the same cosmetics catalog surfaced through the shop_items spine with ?label= filtering and a populated labels block. Distinct section from cosmetics: the shop is read-only merchandising; ownership, purchase, and equip/active selection stay under cosmetics. See docs/systems/cosmetics.md.

Shop storefront — cosmetics via shop_items spine (JWT)

User-facing shop endpoint. Delegates to CosmeticCatalogService::storeCatalog with ?label= filter and locale resolution (Accept-Language header). Returns the same shape as GET /v1/cosmetics with a labels block populated from the shop_item_labels spine. User-agnostic — pair with GET /v1/me/cosmetics for the caller's inventory. Pagination is per type.

Authorizations:
BearerAuth
query Parameters
type
string
Example: type=frame,background,banner

Comma-separated type filter; omitted = all registered types.

tier
string
Enum: "common" "rare" "epic" "legendary"

Narrows every type to one tier.

q
string
Example: q=gold

Name substring filter.

gender
string
Enum: "male" "female" "other"

Gender filter (avatar type only).

label
string
Example: label=featured,new

Comma-separated label key filter (OR); items must have at least one matching label.

limit
integer [ 1 .. 200 ]
Default: 200
offset
integer >= 0
Default: 0

Responses

Response samples

Content type
application/json
{
  • "types": [ ],
  • "labels": [ ],
  • "limit": 200,
  • "offset": 0,
  • "packages": [ ],
  • "gifts": [
    ]
}

Label config — active labels, standalone (JWT)

The labels catalog block served independently of the storefront, so a client can fetch just the label config (key, localized names, placement, icon) without the full store payload. Same shape as the embedded labels block on GET /v1/shop / GET /v1/cosmetics. Locale resolved from Accept-Language.

Authorizations:
BearerAuth
header Parameters
Accept-Language
string
Example: tr

Locale for displayName resolution (defaults to the app default).

Responses

Response samples

Content type
application/json
{
  • "labels": [
    ]
}

Tier config — active tiers, standalone (JWT)

The tiers catalog block served independently of the storefront, so a client can fetch just the tier config (key, localized names, colors, icon) without the full store payload. Same shape as the embedded tiers block on GET /v1/shop / GET /v1/cosmetics. Locale resolved from Accept-Language.

Authorizations:
BearerAuth
header Parameters
Accept-Language
string
Example: tr

Locale for displayName resolution (defaults to the app default).

Responses

Response samples

Content type
application/json
{
  • "tiers": [
    ]
}

Shop package detail (JWT)

Full package incl. resolved contents. Same shape served in the storefront packages block, including the caller's per-user price. Gated to active, listed, in-window packages; anything else returns 404.

Authorizations:
BearerAuth
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "shopItemId": 0,
  • "internalName": "string",
  • "title": {
    },
  • "subtext": {
    },
  • "imageUrl": "string",
  • "imageType": "tall",
  • "layoutType": "banner",
  • "fullBackground": true,
  • "imageWidth": 0,
  • "imageHeight": 0,
  • "currency": "coin",
  • "price": 0,
  • "fullPrice": 0,
  • "discountPercent": 0,
  • "listPrice": 0,
  • "fullyOwned": true,
  • "validFromMs": 0,
  • "validUntilMs": 0,
  • "repurchasable": true,
  • "maxPerUser": 0,
  • "labels": [
    ],
  • "contents": [
    ]
}

Buy a shop item — unified, kind-dispatch (JWT)

Purchases any shop_item by dispatching on its kind. Cosmetics delegate to the existing cosmetic-grant; packages debit coins and grant each content line. Idempotent on clientKey.

Cosmetic lines a buyer already owns are excluded from the grant and the price falls pro-rata, so the advertised discount still holds; the response carries pricePaid, listPrice and skippedOwned[]. A package whose every line is already owned returns 409 PACKAGE_FULLY_OWNED. The acknowledgeOwned flag is accepted and ignored (retired along with ALREADY_OWNS_ITEMS). money-currency packages return 422 NOT_PURCHASABLE_YET until the real-money rail ships.

Authorizations:
BearerAuth
path Parameters
shopItemId
required
integer >= 1
Request Body schema: application/json
required
clientKey
required
string

Client idempotency key.

acknowledgeOwned
boolean
Default: false

Retired — accepted and ignored. Owned lines are skipped and discounted automatically.

Responses

Request samples

Content type
application/json
{
  • "clientKey": "string",
  • "acknowledgeOwned": false
}

Response samples

Content type
application/json
{ }

profile-images

User-uploaded profile photos with CDN-driven moderation. Two X-CDN-Key callbacks push moderation state in; one JWT GET lets the owner poll it. Distinct from the cosmetic avatars catalog — see docs/systems/profile-images.md.

CDN callback — a new profile image entered the pipeline

Server-to-server callback from the CDN/verification VPS. Records that a user-uploaded photo entered moderation. Idempotent on assetId (re-posting the same id updates the existing row), so the CDN can retry safely. After the upsert, the user's denormalized user_profiles.profile_image_url / profile_image_status are recomputed. See docs/systems/profile-images.md.

Authorizations:
CdnKey
Request Body schema: application/json
required
callerid
required
string

Owner identifier (users.callerid — digits only).

assetId
required
string <= 64 characters

CDN's opaque id for the uploaded file (UNIQUE).

imageUrl
required
string <= 500 characters

CDN URL of the image bytes (http/https).

status
string
Default: "pending"
Enum: "pending" "approved" "rejected"

Initial pipeline status. Defaults to pending if omitted. The CDN's unverified is accepted and normalized to pending.

object (ProfileImageLabels)

Free-form moderation signals attached by the CDN/verification VPS (e.g. per-category model scores). Opaque to this backend — stored verbatim as JSON and never interpreted. Nullable.

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "ok": true
}

CDN callback — moderation status transition for an asset

Server-to-server callback: end-of-pipeline status change for an existing asset. Sets reviewed_at when the status leaves pending and recomputes the owner's denormalized columns so an approval immediately surfaces the URL on public read paths.

Authorizations:
CdnKey
path Parameters
assetId
required
string <= 64 characters
Example: asset-9f3a2b

The CDN assetId recorded at ingest.

Request Body schema: application/json
required
status
required
string (ProfileImageStatus)
Enum: "pending" "approved" "rejected"

Moderation state of a profile photo. The CDN's unverified (auto-passed, not human-reviewed) is normalized to pending on ingest, so the wire only ever carries these three values.

object (ProfileImageLabels)

Free-form moderation signals attached by the CDN/verification VPS (e.g. per-category model scores). Opaque to this backend — stored verbatim as JSON and never interpreted. Nullable.

Responses

Request samples

Content type
application/json
{
  • "status": "pending",
  • "labels": {
    }
}

Response samples

Content type
application/json
{
  • "ok": true
}

Set the shared profile slot — a verified photo or an avatar

Chooses what fills the shared profile-picture slot. With source: photo the caller selects one of their own approved uploads (by imageId). With source: avatar the given avatar is equipped and rendered (de-equipping any photo). The choice is global identity — everyone sees it. See docs/systems/profile-images.md.

Authorizations:
BearerAuth
Request Body schema: application/json
required
source
required
string
Enum: "photo" "avatar"

Which kind fills the slot.

imageId
integer <int64>

Required when source=photo — id of one of the caller's approved uploads.

avatarId
integer

Required when source=avatar — id of an owned, usable avatar.

Responses

Request samples

Content type
application/json
{
  • "source": "photo",
  • "imageId": 4210
}

Response samples

Content type
application/json
{}

Read the caller's own profile image and moderation status

Returns the caller's latest uploaded photo (any status), so the client can render under-review / rejected UI. Empty payload (all-null) when the user has never uploaded. Other users only ever see an approved photo, via the public-profile and voice-room read paths — not this endpoint.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{}

List all of the caller's uploaded photos (any status)

Returns every photo the caller has uploaded (any moderation status), newest first — the photo half of the profile-picture picker. Pair the imageId of an approved item with POST /v1/me/profile-picture (source: photo) to set it as the displayed picture. Empty items (not an error) when the user has never uploaded. See docs/systems/profile-images.md.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{}

horoscopes

App-facing read-only horoscope catalog (user's sign is derived from birthDate)

List the active horoscope catalog (localized)

Returns the 12-sign zodiac catalog, active rows only, localized to the caller's locale (query lang / Accept-Language, falling back to English). JWT-authed. A read-only reference list of all signs — a user's own sign is NOT selected here; it is derived from their birthDate and embedded in profile reads.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "horoscopes": [
    ]
}

call

1-on-1 call client config (pricing surface)

1-on-1 call pricing config (client-facing)

Returns the public coin-pricing for 1-on-1 calls (per-minute cost, free-tier minutes, etc.) so the mobile client can show "this call will cost X coins" prompts before initiating.

The non-client-facing fields of CallPricingService (admin-only knobs) are excluded.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Submit post-call quality feedback (1-5 stars + optional issue)

Records the rating + optional issue code that the mobile client collects after a 1:1 call ends (only shown when the call lasted

= 90 seconds; that gate is mobile-side).

Idempotent on (user_id, sessionUuid) -- a duplicate POST returns the previously-stored feedback row with replayed: true and HTTP 200 instead of 201. The caller must be one of the two participants of the referenced call_sessions row, otherwise 403.

issueCode is required when rating <= 3 and must be one of: audio_cut, echo, disconnect, latency, no_remote_voice. For ratings >= 4, issueCode and issueText are ignored.

Authorizations:
BearerAuth
path Parameters
sessionUuid
required
string

The call_sessions.session_uuid the feedback belongs to.

Request Body schema: application/json
required
rating
required
integer [ 1 .. 5 ]

User-given star rating (1..5).

issueCode
string
Enum: "audio_cut" "echo" "disconnect" "latency" "no_remote_voice"

Required when rating <= 3. Server-side whitelist; UI labels are mapped client-side from the code. Ignored when rating >= 4.

issueText
string <= 280 characters

Optional free-text follow-up. Trimmed and truncated to 280 chars server-side; rejected if it contains http://, https://, or www.. Ignored when rating >= 4.

callDurationSec
integer >= 0

Client-claimed call duration in seconds (analytics-only; server also stores its own derived duration).

platform
string
Enum: "ios" "android"

Mobile platform string. Unknown values are stored as null.

appVersion
string <= 32 characters

Client app version (free-form, capped at 32 chars).

sentAtMs
integer <int64> >= 0

Client-side wall-clock timestamp (epoch ms) at submit time. Diagnostic only.

Responses

Request samples

Content type
application/json
{
  • "rating": 1,
  • "issueCode": "audio_cut",
  • "issueText": "string",
  • "callDurationSec": 0,
  • "platform": "ios",
  • "appVersion": "string",
  • "sentAtMs": 0
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Submit post-call user rating (social moderation)

Persists a 1..5 star rating left by one participant of a 1:1 call for the other. Distinct from quality-feedback, which collects audio/network issues; this endpoint collects social-moderation signals (inappropriate, underage, other).

Rules:

  • rating < 3 requires reasonCode.
  • reasonCode == "other" requires reasonText (max 240 chars).
  • For ratings >= 3, server drops any reason fields.
  • Idempotent on (userId, sessionUuid). A duplicate POST returns HTTP 200 with replayed: true and the original row; first POST returns HTTP 201.
  • Caller (JWT-bound userId = phone) must be one of the two participants of the session (call_sessions.user_phone1 or user_phone2); otherwise 403 FORBIDDEN.
Authorizations:
BearerAuth
path Parameters
sessionUuid
required
string

Call session UUID (= call_sessions.match_uuid).

Request Body schema: application/json
required
rating
required
integer [ 1 .. 5 ]

Star rating from 1 to 5.

reasonCode
string or null
Enum: "inappropriate" "underage" "other" null

Required when rating < 3. Must be omitted/null otherwise (the server drops it for ratings >= 3).

reasonText
string or null <= 240 characters

Free-text explanation. Accepted only when reasonCode == "other"; trimmed to 240 chars and ignored for any other reason code or rating >= 3.

callDurationSec
integer or null >= 0

Client-reported call duration in seconds (analytics).

ratedAtMs
integer or null

Client clock at submission, epoch ms (analytics).

platform
string or null
Enum: "ios" "android" null

Mobile platform (analytics).

appVersion
string or null <= 32 characters

Mobile app version string (analytics).

Responses

Request samples

Content type
application/json
{
  • "rating": 1,
  • "reasonCode": "inappropriate",
  • "reasonText": "string",
  • "callDurationSec": 0,
  • "ratedAtMs": 0,
  • "platform": "ios",
  • "appVersion": "string"
}

Response samples

Content type
application/json
{
  • "ratingId": "e2685a8c-cb0d-4ffd-85d1-e4f8966e7aa9",
  • "sessionUuid": "string",
  • "rating": 1,
  • "reasonCode": "inappropriate",
  • "storedAtMs": 0,
  • "replayed": true
}

End a 1-on-1 call session (REST fallback)

Ends an active call_sessions row when the call WebSocket is already dead on the client (app kill, backgrounding, mid-reconnect teardown). The primary end path stays the WS endCall action; the mobile client calls this only when the WS ack never arrives.

Rules:

  • Caller (JWT-bound userId = phone) must be one of the two participants of the session, otherwise 403 FORBIDDEN.
  • Idempotent: ending an already-ended session returns HTTP 200 with alreadyEnded: true and does not overwrite the original end metadata.
  • On a real end, an ENDCALL event is pushed onto the REST→call-WS bridge so the peer's client is notified.
  • reason is sanitized to [a-z0-9_], max 50 chars; defaults to user_closed.
Authorizations:
BearerAuth
path Parameters
sessionUuid
required
string

The call_sessions.session_uuid to end.

Request Body schema: application/json
optional
reason
string <= 50 characters ^[a-z0-9_]*$
Default: "user_closed"

Client-declared end reason for server logs, e.g. user_closed, app_background, app_disposed. Sanitized server-side.

Responses

Request samples

Content type
application/json
{
  • "reason": "user_closed"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

dm

1:1 direct messages. The backend is the single source of truth (dm_* tables); LiveKit delivers realtime deltas to per-user dm_inbox_<callerid> rooms and FCM covers offline recipients. /api/dm/notify is the legacy ZIM-era push trigger, kept one release for old clients. /api/dm/bot/start opens a conversation with the reactive Talkbot AI assistant account (see docs/systems/talkbot.md).

Trigger a push notification for a 1-1 DM just sent via ZIM

Fire-and-forget endpoint called by the sender's client immediately after ZIMKit().sendTextMessage(...) resolves. The backend never sees the message body for storage — Zego is the message transport; this endpoint exists solely to deliver an FCM push to the receiver's registered devices.

Idempotent on messageId (ZIM message id). A retry of the same messageId returns the original result without re-pushing.

Authentication is required; the sender is taken from the JWT, NOT from the body. receiverUserId is the receiver's users.callerid (digits-only string, same form ZIM uses as its userID).

Behaviour: if the receiver has globally muted DM pushes, no devices registered, or has blocked the sender, the call still returns 200 with delivered: 0 (errors never roll back the chat UI).

Authorizations:
BearerAuth
Request Body schema: application/json
required
messageId
required
string <= 64 characters

ZIM message id (ZIMMessage.messageID). Used as the idempotency key — duplicate calls with the same value return the original result without re-pushing.

conversationId
string

Stable conversation key. Defaults to receiver's callerid when omitted. Used for client-side routing back into the chat thread. NOTE: this legacy endpoint does not set apns.thread-id / Android tray grouping — that peer-scoped collapse behavior (dm_peer_<senderCallerid>) is implemented only on the LiveKit-era send path (DmPushService, driven by POST /api/dm/conversations/{peerId}/messages), not here.

receiverUserId
required
string

Receiver's users.callerid (digits-only string). Same form ZIM uses for its userID, so the client can pass the peer's ZIM userID verbatim.

textPreview
string

Short plain-text preview to surface in the notification body. Truncated server-side to 140 characters; longer values are shortened with an ellipsis. Empty string is allowed (client may omit if the user has disabled previews locally).

createdAtMs
integer <int64>

Epoch milliseconds when the message was sent. Optional — if 0 or missing, the server stamps with its own clock.

Responses

Request samples

Content type
application/json
{
  • "messageId": "1745890000000123",
  • "conversationId": "5511999998888",
  • "receiverUserId": "5511999998888",
  • "textPreview": "Selam, naber?",
  • "createdAtMs": 1745890000000
}

Response samples

Content type
application/json
{
  • "delivered": 0,
  • "tokens": 0,
  • "replayed": true
}

Open (or re-open) the DM conversation with the Talkbot AI assistant

Reactive kickoff — the bot account never DMs cold. Calling this the first time posts a sentinel content string to the Talkbot webhook and persists the returned reply as the bot's first DM (with the usual realtime fan-out + offline push). Calling it again on an already-started conversation is a no-op that just returns the current state (idempotent — no second webhook call).

peerId in the response is the bot's callerid; every subsequent interaction (send, read, typing, list) uses the regular /api/dm/conversations/{peerId}/* endpoints with that value.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "peerId": "string",
  • "conversationId": "string",
  • "started": true,
  • "frozen": true,
  • "isBot": true,
  • "message": {
    }
}

List the caller's DM conversations with unread counts

Conversation list for the Messages tab, newest activity first (updated_at_ms DESC, id DESC). Each entry carries the peer's resolved display name and avatar, the last visible message, and the caller's unread count. Conversations soft-deleted by the caller (with nothing newer) are omitted.

Paged with an opaque keyset cursor: pass cursor = the previous page's nextCursor. Omitting limit returns up to 100 in one page, which is what pre-cursor clients rely on.

Authorizations:
BearerAuth
query Parameters
limit
integer <= 100
Default: 100

Page size. Default 100 — clients that send nothing get the full legacy page.

cursor
string

Opaque keyset cursor from the previous page's nextCursor.

Responses

Response samples

Content type
application/json
{
  • "conversations": [
    ],
  • "hasMore": true,
  • "nextCursor": "string"
}

Soft-delete the caller's view of a conversation

Sets the caller's cleared watermark to now — messages before this moment disappear from the caller's list and history. The peer's view is unaffected. A new message from either side revives the thread with only the new content. Idempotent; also emits a dm_conversation_deleted LiveKit event to the caller's own inbox for multi-device sync.

Authorizations:
BearerAuth
path Parameters
peerId
required
string

Peer's callerid.

Responses

Response samples

Content type
application/json
{
  • "ok": true
}

Paged DM history with a peer

Pages are always newest-first (id DESC) in both directions, so the client has one parsing path.

direction=before (default) walks older messages; direction=after walks newer and is meant for reconnect gap-fill — it REQUIRES a cursor (DM_CURSOR_REQUIRED), because without one it would mean "from the dawn of the thread". hasMore and nextCursor are direction-relative: they always describe more rows in the direction that was requested.

Pass cursor = the previous page's nextCursor. beforeId is the legacy backward cursor and still works unchanged; cursor supersedes it when both are sent.

Messages hidden by the caller's soft-delete watermark are excluded in both directions. readAtMs is derived from the recipient's read cursor. An unknown conversation returns an empty page rather than 404 (the client opens an empty chat).

Authorizations:
BearerAuth
path Parameters
peerId
required
string

Peer's callerid.

query Parameters
limit
integer <= 100
Default: 30
cursor
string

Opaque keyset cursor from the previous page's nextCursor.

direction
string
Default: "before"
Enum: "before" "after"

before = older messages; after = newer (requires cursor).

beforeId
integer <int64>

Legacy backward cursor — return only messages with id < beforeId.

Responses

Response samples

Content type
application/json
{
  • "messages": [
    ],
  • "hasMore": true,
  • "nextCursor": "string"
}

Send a DM (persist + realtime fan-out + offline push)

Persists the message, then (post-commit) publishes a dm_message LiveKit data event to BOTH the recipient's and the sender's dm_inbox_* rooms (event includes messageId: dm_<row id>, same string as FCM data.messageId), and may enqueue an FCM push per ALWAYS_ENQUEUE_DM_FCM / SendData gate.

Relation gate (403). The pair must be friends with no block in either direction; the UI composer lock is not a security boundary. Bot peers (isBot: true) are exempt from the friendship requirement. The gate can be turned off operationally via the dm_require_friendship system config. Reading history is NOT gated — a lost friendship makes the thread read-only, not invisible.

Mute. If the recipient has muted this conversation, the LiveKit delta and the unread count are unaffected; only the FCM push is skipped.

Idempotent on clientMessageId (uuid v4): a retried POST with the same value returns the original row without re-notifying.

Authorizations:
BearerAuth
path Parameters
peerId
required
string

Peer's callerid.

Request Body schema: application/json
required
text
required
string <= 4000 characters

Plain text; control characters are stripped server-side.

clientMessageId
string

Optional uuid v4 idempotency key. Retrying the same value returns the original message without duplicating it or re-notifying.

Responses

Request samples

Content type
application/json
{
  • "text": "string",
  • "clientMessageId": "string"
}

Response samples

Content type
application/json
{
  • "message": {
    }
}

Advance the caller's read cursor (double-tick)

Upserts the caller's per-conversation read cursor to now and emits a dm_read LiveKit event to the peer's inbox so the sender's ticks update instantly. Rate limit 5/s.

Authorizations:
BearerAuth
path Parameters
peerId
required
string

Peer's callerid.

Responses

Response samples

Content type
application/json
{
  • "ok": true
}

Publish a typing signal (no persistence)

Publishes dm_typing to the peer's LiveKit inbox. Nothing is written to the DB. Throttled to 1/s per (caller, peer); throttled calls, and calls that fail the same relation gate as POST .../messages (block either way, or not friends), still return 200 with the publish silently skipped so the client never special-cases them.

Authorizations:
BearerAuth
path Parameters
peerId
required
string

Peer's callerid.

Responses

Response samples

Content type
application/json
{
  • "ok": true
}

Mute this conversation's notifications (one-way)

The middle option between "put up with it" and "unfriend/block": the caller stops getting FCM pushes for this peer while messages keep arriving and keep counting as unread (WhatsApp behaviour).

Server-side by necessity — an iOS APNs alert cannot be suppressed by the client once the OS has it.

Idempotent (upsert). Also publishes dm_conversation_muted to the CALLER's own inbox for multi-device sync. Works for bot peers too.

Authorizations:
BearerAuth
path Parameters
peerId
required
string

Peer's callerid.

Request Body schema: application/json
optional
durationMs
integer <int64> [ 0 .. 31536000000 ]

Optional. Omitted / null / 0 = indefinite. Otherwise the mute expires durationMs from now (max 1 year).

Responses

Request samples

Content type
application/json
{
  • "durationMs": 3600000
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "isMuted": true,
  • "mutedUntilMs": 0
}

Un-mute this conversation

Deletes the mute row (idempotent — un-muting a conversation that was never muted returns 200) and publishes dm_conversation_muted with isMuted: false to the caller's own inbox.

Authorizations:
BearerAuth
path Parameters
peerId
required
string

Peer's callerid.

Responses

Response samples

Content type
application/json
{
  • "ok": true,
  • "isMuted": true,
  • "mutedUntilMs": 0
}

livekit

LiveKit access-token minting (self-hosted SFU). One endpoint serves voice rooms, in-game voice and DM inbox rooms with a per-pattern authorization matrix; the API secret never leaves the backend.

Mint a LiveKit access token for a room (voice / game / DM inbox)

Single token endpoint for every LiveKit room. Replaces client-side token signing — LIVEKIT_API_SECRET lives only on the backend.

Authorization matrix by room-name pattern:

Room pattern Rule Permissions
dm_inbox_<id> <id> must equal the caller's callerid subscribe only
game_<roomId> any active authenticated user subscribe + publish
other must be a registered ACTIVE voice room (voice_rooms.zego_room_id) subscribe + publish + publishData

Identity is derived server-side: callerid for DM inbox rooms, u_<uuid> (from the caller's own users.uuid) for media rooms. The optional identity query param, when present, must match the derived value. Banned accounts (users.status != active) are refused for every pattern. Rate limit 10/min per user.

Authorizations:
BearerAuth
query Parameters
room
required
string <= 128 characters

LiveKit room name (charset [A-Za-z0-9_.-]).

identity
string

Optional client-claimed identity; must match the derived one.

Responses

Response samples

Content type
application/json
{
  • "url": "wss://tcfunkit.telpass-ltd.live",
  • "token": "string",
  • "expiresAtMs": 0,
  • "identity": "string"
}

notify

Push-notification trigger (Firebase fan-out)

Send a push notification (Firebase fan-out)

Server-initiated notification trigger. Body shape is consumer-specific (caller passes through Firebase notification payload). Returns the underlying delivery result.

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

account

Account-scoped settings: device/FCM token registration, profile bits. All endpoints require JWT auth and operate on the calling user.

Register or refresh the caller's FCM device token

Persists the caller's Firebase Cloud Messaging registration token into user_devices so DM push notifications can target this device.

Called by the Flutter client at two points:

  • App start — idempotent backfill so users provisioned before the device-tracking system landed (user_devices empty for them) start receiving push without needing to log out/in.
  • FirebaseMessaging.onTokenRefresh — token rotation, app reinstall, restore-from-backup, etc.

Replaces the never-routed legacy firebaseTokenUpdate endpoint.

deviceId is optional. When omitted the server synthesises one from sha256(userId:token) — stable per (user, token) but a rotation produces a new row (the prior row's stale token is nulled out by DmPushService on the next send attempt that hits UNREGISTERED).

Authorizations:
BearerAuth
Request Body schema: application/json
required
token
required
string [ 32 .. 4096 ] characters

FCM registration token (FirebaseMessaging.instance.getToken()). Persisted in user_devices.firebase_token and used by the DM push dispatcher.

deviceType
string

Free-form platform tag — typically ios or android.

appVersion
string

Client version for triage (any string the client uses).

deviceId
string

Stable per-install identifier the client persists locally (Hive UUID, etc.). When omitted the server synthesises an auto-<hash> deviceId — works fine, but token rotation creates a new user_devices row instead of updating the existing one. Sending a real deviceId is recommended once the client wires it in.

Responses

Request samples

Content type
application/json
Example
{
  • "token": "fA9k_LdEQUe1mQ2xR7vN0T:APA91bH3kZq8YQ7Pr3nV2sD4wX6yL1cM9jF5gK0oB8tR2eU7iA3pN6hS4dW1zX",
  • "deviceType": "android",
  • "appVersion": "2.14.0",
  • "deviceId": "a3f1c9d2e8b4"
}

Response samples

Content type
application/json
{
  • "ok": true
}

system-messages

User-facing inbox for admin/system messages. Rendered pinned at the top of the messaging screen, separate from Zego DMs. Soft-delete is per-user; admin force-delete is a separate hard delete.

User inbox — chronological (oldest first), unified across personal + announcement kinds

Returns the calling user's inbox rows. The Flutter messaging screen renders these pinned at the top, separate from Zego DMs. Soft-deleted rows (DELETE /api/system-messages/{kind}/{id}) are filtered out.

Each row carries a kind discriminator: personal (per-user system_messages rows) or announcement (broadcast definitions with lazy per-user state — new users register-after-send still catch up to currently-valid announcements on first inbox fetch).

Order defaults to ASC by scheduledAt (target visibility time, not row creation time). Pass direction=desc to flip. The full sort key is (scheduledAt, kind, id)id alone is not unique across the two kinds, since personal rows and announcements are independent sequences.

Two paging mechanisms: the keyset cursor (preferred) and the legacy offset. cursor supersedes offset when both are sent.

Authorizations:
BearerAuth
query Parameters
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Legacy offset paging. Ignored when cursor is supplied.

direction
string
Default: "asc"
Enum: "asc" "desc"

asc (oldest first, default) — chronological conversation view. desc — feed-style.

cursor
string

Opaque keyset cursor over (scheduledAt, kind, id) from the previous page's nextCursor. It carries the direction it was minted under — replaying a desc cursor with direction=asc returns 400 INVALID_CURSOR rather than silently paging backwards through rows already seen.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "limit": 1,
  • "offset": 0,
  • "direction": "asc",
  • "hasMore": true,
  • "nextCursor": "string"
}

Single collapsed preview of the user's SYSTEM messagebox

Returns one row representing the user's entire SYSTEM messagebox — live inbox title (resolved from system_config, NOT frozen onto the row), live unread count, and the newest deliverable inbox row's body

  • timestamp + id. Drives the "ONE SYSTEM messagebox per user" invariant on the messages-list screen: the client renders a single tile from this response instead of paging the inbox and rendering N tiles.

hasMessages: false means the user has zero deliverable rows (empty inbox, or every row soft-deleted / suppressed / permanent failed); the client should hide the SYSTEM tile entirely.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "title": "[SYSTEM]",
  • "unreadCount": 0,
  • "hasMessages": true,
  • "lastMessage": "string",
  • "lastAt": "2019-08-24T14:15:22Z",
  • "lastId": 0
}

Count of un-read, non-deleted inbox rows

Drives the messaging-tab badge.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "unread": 0
}

Mark a single inbox row read

Idempotent — re-marking an already-read row is a no-op.

kind is one of personal (system_messages row) or announcement (user_announcement_state row keyed on the broadcast definition). IDs are namespaced per kind.

Authorizations:
BearerAuth
path Parameters
kind
required
string
Enum: "personal" "announcement"
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

Soft-delete a single inbox row (hidden from user; kept for admin audit)

kind is one of personal or announcement — see markRead.

Authorizations:
BearerAuth
path Parameters
kind
required
string
Enum: "personal" "announcement"
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

app-review

In-app rating survey. Users submit 1-5 whole stars; ratings <= 3 surface a dynamic, admin-managed reason catalog (one "other" reason opens a free-text box). Supersedes the legacy survey surface.

Active low-rating reason catalog (app-facing)

Returns the active reason options the client renders when the user selects a rating of 3 stars or lower. Ordered by the catalog's sortOrder. The reason flagged isFreetext (seeded as other) tells the client to open a free-text box when chosen.

Reasons are managed dynamically in the admin panel, so the app must render whatever this endpoint returns rather than a hardcoded list.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Submit an app rating (1-5 stars + optional low-rating reason)

Records one app rating. rating is a whole number 1-5 (fractional values like 4.5 are rejected).

When rating <= 3, reasonCode is required and must be an active catalog code; if that reason is a free-text reason, reasonText is also required. Links in reasonText are rejected and text is capped at 280 chars. For rating >= 4, reasonCode/reasonText are ignored. Submissions are repeatable — every call inserts a new row.

Authorizations:
BearerAuth
Request Body schema: application/json
required
rating
required
integer [ 1 .. 5 ]

Whole stars only (1-5). Fractional values are rejected.

reasonCode
string or null

Required when rating <= 3; must be an active catalog code. Ignored for rating >= 4.

reasonText
string or null <= 280 characters

Required when the chosen reason is a free-text reason (e.g. "other"). Links are rejected.

platform
string or null
Enum: "ios" "android"
appVersion
string or null <= 32 characters
sentAtMs
integer or null <int64>

Client send time in epoch milliseconds (informational).

Responses

Request samples

Content type
application/json
{
  • "rating": 2,
  • "reasonCode": "too_many_ads",
  • "reasonText": "Uygulama çok sık donuyor",
  • "platform": "ios",
  • "appVersion": "1.42.0",
  • "sentAtMs": 0
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List all reason catalog rows (incl. inactive)

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": [
    ]
}

Create a reason

code must match ^[a-z0-9_]{2,48}$ and be unique.

Authorizations:
AdminKey
Request Body schema: application/json
required
code
required
string^[a-z0-9_]{2,48}$
labelTr
required
string <= 120 characters
isFreetext
boolean
Default: false
sortOrder
integer
Default: 0

Responses

Request samples

Content type
application/json
{
  • "code": "slow_loading",
  • "labelTr": "Yavaş açılıyor",
  • "isFreetext": false,
  • "sortOrder": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Update a reason (label / freetext / active / sort; code immutable)

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
labelTr
string <= 120 characters
isFreetext
boolean
isActive
boolean
sortOrder
integer

Responses

Request samples

Content type
application/json
{
  • "labelTr": "string",
  • "isFreetext": true,
  • "isActive": true,
  • "sortOrder": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

App-rating aggregation report

Aggregates submissions: total count, average rating, star distribution, low-rating reason breakdown (labelled from the catalog), and the most recent free-text entries.

Authorizations:
AdminKey
query Parameters
from
integer <int64>

Lower bound, epoch ms.

to
integer <int64>

Upper bound, epoch ms.

platform
string
Enum: "ios" "android"

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

social-share

Client-verified social share rewards. One coin payout per social channel per user, for life. The backend never verifies the share — it authenticates the JWT, enforces the one-claim rule with a database unique constraint, and pays the per-channel configurable amount.

Active share channels with the caller's claim state

Returns the active social share channels, ordered by sortOrder, each carrying the calling user's claim state so the client can grey out channels whose reward is already spent.

Channels are managed dynamically in the admin panel, so the app must render whatever this endpoint returns rather than a hardcoded list. Inactive channels are omitted entirely — including for a user who already claimed them.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Claim the one-off coin reward for a social channel

Grants the channel's configured coin amount to the authenticated caller, once per channel for life. Every later call for the same channel returns 409 ALREADY_CLAIMED.

The backend does not verify that a share actually happened — there is no platform API call, no scraping, no proof. Verification is entirely client-side; the server authenticates the JWT, enforces the one-claim rule via a database unique constraint, and pays. See the ADR docs/decisions/2026-07-27-social-share-client-verified.md.

shareUrl and handle are optional client-supplied evidence. They are stored verbatim for operator review, validated for shape only, and are never an input to the payout decision.

The amount paid is the channel's rewardCoins at claim time; it is snapshotted on the claim row, so a later price change does not re-price earlier claims.

Authorizations:
BearerAuth
Request Body schema: application/json
required
channelCode
required
string

Catalog code of the channel being claimed.

shareUrl
string or null <= 512 characters

Optional link to the post. Must be http/https when present.

handle
string or null <= 64 characters

Optional social handle. A leading "@" is stripped.

platform
string or null
Enum: "ios" "android"
appVersion
string or null <= 32 characters

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List all share channels (incl. inactive)

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": [
    ]
}

Create a share channel

code must match ^[a-z0-9_]{2,32}$ and be unique. iconUrl, when present, must be http/https.

Authorizations:
AdminKey
Request Body schema: application/json
required
code
required
string^[a-z0-9_]{2,32}$
labelTr
required
string <= 64 characters
rewardCoins
required
integer >= 0
iconUrl
string or null
isActive
boolean
Default: true
sortOrder
integer
Default: 0

Responses

Request samples

Content type
application/json
{
  • "code": "tiktok",
  • "labelTr": "TikTok",
  • "rewardCoins": 250,
  • "iconUrl": null,
  • "isActive": true,
  • "sortOrder": 2
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Update a channel (label / reward / icon / active / sort; code immutable)

Partial update. code is immutable — a different value in the body is ignored rather than rejected.

Changing rewardCoins affects future claims only; existing claim rows keep the amount snapshotted when they were made.

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
labelTr
string <= 64 characters
rewardCoins
integer >= 0
iconUrl
string or null
isActive
boolean
sortOrder
integer

Responses

Request samples

Content type
application/json
{
  • "labelTr": "string",
  • "rewardCoins": 0,
  • "iconUrl": "string",
  • "isActive": true,
  • "sortOrder": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Paged claim list with totals and a per-channel breakdown

totals and items honour the channel filter; byChannel always reports every channel in the date window so an operator can compare.

Authorizations:
AdminKey
query Parameters
from
integer <int64>

Window start, epoch ms.

to
integer <int64>

Window end, epoch ms.

channel
string
Example: channel=instagram

Filter to one channel code.

limit
integer [ 1 .. 200 ]
Default: 50

Page size, clamped to 1-200.

offset
integer >= 0
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

version

App force-update and service-health probes

App update gate decision (build-number based)

Returns whether the calling client must update (forced), should update (soft), or is current (none). Driven by X-App-Build and X-App-Platform headers and the system_config floors. Public.

header Parameters
X-App-Build
required
integer
X-App-Platform
required
string
Enum: "android" "ios"

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Force-update / soft-update gating for the mobile client Deprecated

The client posts its versionNo (or appVersion) and platform; the server returns whether the install needs to be updated, and whether the update is mandatory. Public route — no JWT required.

Request Body schema: application/json
required
versionNo
string
appVersion
string

Alias.

platform
string

e.g. android, ios.

property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "versionNo": "string",
  • "appVersion": "string",
  • "platform": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update the server-side version registry (admin/internal) Deprecated

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Check upstream / dependency health (legacy probe) Deprecated

Public route — no JWT required.

Request Body schema: application/json
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

swipe

Legacy 1-on-1 swipe / call analytics counters

Record a 1-on-1 swipe action (legacy analytics)

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Increment a generic counter (legacy)

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Record a call session (legacy)

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Increment lifetime swipe counter

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

survey

Legacy survey send/check/recorded

Send/serve a survey to a user Deprecated

DEPRECATED. Superseded by POST /app-review (see docs/systems/app-review.md).

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Check a user's survey state / response Deprecated

DEPRECATED. Superseded by the /app-review surface (see docs/systems/app-review.md).

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Mark a survey as completed/recorded Deprecated

DEPRECATED. Superseded by the /app-review surface (see docs/systems/app-review.md).

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

point

Legacy loyalty-point currency (separate from coins)

Read a user's loyalty-point balance (legacy)

Loyalty points are a separate currency from coins (see /getCoinBalance). Used by the old reward / streak surface.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
integer
property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "userId": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Subtract loyalty points (legacy)

Endpoint name preserves the original misspelling (Substract) for backward compatibility with mobile clients.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
integer
amount
integer
property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "userId": 0,
  • "amount": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Apply a points transaction (multi-purpose)

Authorizations:
BearerAuth
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

whatsapp

WhatsApp pairing / service multiplex (phantom service — verify)

Get a WhatsApp pairing QR code

Returns a QR-code URL or payload that the mobile client renders for WhatsApp account linking. The actual WhatsApp integration lives in the WhatsAppService (currently a phantom — see CLAUDE.md "Phantom services" — implementation is external/stubbed).

Authorizations:
BearerAuth
Request Body schema: application/json
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

WhatsApp service multiplex (legacy)

Generic WhatsApp service multiplexer — the body's action field selects the underlying operation. See the WhatsAppService implementation (phantom; verify before relying on this endpoint).

Authorizations:
BearerAuth
Request Body schema: application/json
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

test

Test-only seed and cleanup endpoints (refused outside APP_ENV=testing)

Idempotently create the JWT-auth caller as a `users` row (TEST ENV ONLY)

Inserts (or no-ops) the JWT subject's callerid into users so test scripts can sign in via OTP and immediately operate on a known user. Returns { userId, appUserId }.

Refused with 403 FORBIDDEN outside test env (APP_ENV !== 'testing').

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Wipe the JWT-auth caller's data (TEST ENV ONLY)

Deletes the caller's rows from wheel_rounds (daily-scope), reward_claims, coin_history, user_wallet_summary, and users (FK CASCADE handles user_allowances, user_allowance_history, voice-room rows via host_user_id, etc.). Used by the integration test runner between cases.

Refused with 403 FORBIDDEN outside test env.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-users

Admin per-user state operations. FTU reset = bring this user back to first-time-user state (preserve identity + IAP audit, wipe gameplay state, mirror RC zero, IVR VIP delete). Two-phase by contract: dryRun:true first, then dryRun:false.

Reset a user back to first-time-user (FTU) state

Brings the target user back to a fresh-signup state without deleting the users row, OAuth identities, or IAP forensic trails. Mirror in spirit of database/maintenance/launch_reset_truncate.sql but scoped to a single callerid and live-safe.

Two-phase by contract. First call with dryRun:true to inspect what would be wiped. Re-call with dryRun:false to apply.

What gets wiped (live-mode):

  • Wallet: zeroed via a synthetic coin_history row tagged with reference_id="ftu_reset:{callerId}:{date}" and metadata reason="ftu_reset". coin_history itself is NOT truncated — the row is preserved for monitoring/finance reporting.
  • RC mirror: WalletService synchronously zeros RC. A defensive second pass adjusts RC if it drifted positive.
  • Allowance ledger + balances (user_allowances, user_allowance_history).
  • Reward streak (reward_claims).
  • Wheel runtime (wheel_bets, user_daily_wheel_spins, scope=daily rows in wheel_rounds).
  • Voice room runtime (voice_room_seat_timers, voice_room_daily_sits, voice_room_members).
  • Hosted voice rooms owned by this user are closed (deleted + room_closed broadcast to listeners).
  • VIP is cancelled on IVR via subscriptionsDelete. IVR failure does NOT abort the rest of the reset; surfaced in result.vip so the admin can retry manually.
  • Heart edges, friendships, friend requests, blocks, profile + interests, owned avatars, system message inbox.
  • Auth: user_sessions, user_refresh_tokens, user_devices cleared so the next login forces a full re-auth flow.
  • Counters on users (calls / swipes / hearts / last_login_at).
  • Daily-streak / attendance reward (Firestore daily_streak_users/{callerId} doc) is deleted so the re-FTU'd user starts day-0 on next claim. Best-effort: Firestore unreachable → skip rather than abort the reset.

What is preserved:

  • users row itself (callerid, uuid, user_key, display_name).
  • user_identities (OAuth bindings).
  • user_purchases, paymentHistory, revenuecat_webhook_events (IAP forensic trails).
  • coin_history (we add one synthetic row, never truncate).

Idempotency. Defaults to ftu_reset:{callerId}:{YYYY-MM-DD}. Same-day replays no-op via the wallet's reference_id unique index and RC's Idempotency-Key header. Override only if testing multi-reset-per-day flows.

Authorizations:
AdminKey
path Parameters
callerId
required
string

Target user callerid (digits-only, max 20 chars).

Request Body schema: application/json
optional
dryRun
boolean
Default: true

When true (default), no writes happen — response contains a preview block of counts. When false, the reset is applied and response contains a result block.

idempotencyKey
string

Optional. Defaults to ftu_reset:{callerId}:{YYYY-MM-DD}. Used as the namespace prefix for the wallet ledger reference and the RC Idempotency-Key header.

Responses

Request samples

Content type
application/json
{
  • "dryRun": true,
  • "idempotencyKey": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Mark/unmark a staff (dev) account

Flips a user's account_type between human and dev. Used by tp_panel to tag our own staff accounts so the online-count monitoring API (GET /admin/presence/online-count) reports them under dev and excludes them from the real players tally that drives alerts.

Refuses synthetic: that value is owned by the synthetic-user pipeline, and an account already synthetic cannot be changed from this endpoint (returns 409 CANNOT_MODIFY_SYNTHETIC). Setting the account to its current type is a no-op. On any change the cached dev-set (presence:dev_set) is invalidated so the next sweep reclassifies immediately. Auth via X-Admin-Key (ADMIN_API_KEY).

Authorizations:
AdminKey
path Parameters
callerId
required
string

Target user callerid (digits-only, max 20 chars).

Request Body schema: application/json
required
accountType
required
string
Enum: "human" "dev"

New account type.

Responses

Request samples

Content type
application/json
{
  • "accountType": "dev"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

admin-allowances

Admin-panel surface — catalog CRUD, tier policies, grant rules, per-user grants, history

List allowance catalog items

Authorizations:
AdminKey
query Parameters
active
string
Enum: "true" "false"
Example: active=true

Filter by is_active (tri-state — omit for "all").

q
string
Example: q=avatar_slot

Partial match on item_key OR display_name_tr.

limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Create a catalog item

Authorizations:
AdminKey
Request Body schema: application/json
required
itemKey
required
string <= 64 characters ^[a-z0-9_]+$
displayNameTr
required
string <= 128 characters
descriptionTr
string
maxCount
integer or null >= 0
expiryMode
required
string
Enum: "none" "per_balance" "per_grant"
expirySeconds
integer or null >= 1
params
any

Arbitrary JSON (object, array, or JSON-encoded string).

isActive
boolean
Default: true

Responses

Request samples

Content type
application/json
{
  • "itemKey": "string",
  • "displayNameTr": "string",
  • "descriptionTr": "string",
  • "maxCount": 0,
  • "expiryMode": "none",
  • "expirySeconds": 1,
  • "params": null,
  • "isActive": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Get a catalog item

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Patch a catalog item (partial update)

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
non-empty
displayNameTr
string <= 128 characters
descriptionTr
string or null
maxCount
integer or null >= 0
expiryMode
string
Enum: "none" "per_balance" "per_grant"
expirySeconds
integer or null >= 1
params
any
isActive
boolean

Responses

Request samples

Content type
application/json
{
  • "displayNameTr": "string",
  • "descriptionTr": "string",
  • "maxCount": 0,
  • "expiryMode": "none",
  • "expirySeconds": 1,
  • "params": null,
  • "isActive": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Soft-disable a catalog item (sets `isActive=false`)

Hard delete is not exposed; user_allowance_history references would orphan.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

List tier policies for an item

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Upsert a tier policy

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
tier
required
string
Enum: "normal" "vip"
Request Body schema: application/json
required
isEnforced
required
boolean
notes
string

Pass "" or omit to clear.

Responses

Request samples

Content type
application/json
{
  • "isEnforced": true,
  • "notes": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

List grant sources

Authorizations:
AdminKey
query Parameters
active
string
Enum: "true" "false"
Example: active=true

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Create a grant source

Authorizations:
AdminKey
Request Body schema: application/json
required
sourceKey
required
string^[a-z0-9_]+$
displayName
required
string
isActive
boolean
Default: true

Responses

Request samples

Content type
application/json
{
  • "sourceKey": "string",
  • "displayName": "string",
  • "isActive": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Patch a grant source

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
non-empty
displayName
string
isActive
boolean

Responses

Request samples

Content type
application/json
{
  • "displayName": "string",
  • "isActive": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Soft-disable a grant source

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

List grant rules

Authorizations:
AdminKey
query Parameters
itemId
integer
Example: itemId=12
sourceId
integer
Example: sourceId=3
tier
string
Enum: "normal" "vip"
Example: tier=vip
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Create a grant rule

(itemId, grantSourceId, tier) is unique — duplicates return 409 GRANT_RULE_CONFLICT.

Authorizations:
AdminKey
Request Body schema: application/json
required
itemId
required
integer
grantSourceId
required
integer
tier
required
string
Enum: "normal" "vip"
amount
required
integer >= 0
strategy
required
string
Enum: "set_to" "add" "set_to_at_least"
refillIntervalSeconds
integer or null >= 1
isActive
boolean
Default: true
params
any

Responses

Request samples

Content type
application/json
{
  • "itemId": 0,
  • "grantSourceId": 0,
  • "tier": "normal",
  • "amount": 0,
  • "strategy": "set_to",
  • "refillIntervalSeconds": 1,
  • "isActive": true,
  • "params": null
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Get a grant rule

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Patch a grant rule

itemId, grantSourceId, tier are immutable — create a new rule to change them.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
non-empty
amount
integer >= 0
strategy
string
Enum: "set_to" "add" "set_to_at_least"
refillIntervalSeconds
integer or null >= 1
isActive
boolean
params
any

Responses

Request samples

Content type
application/json
{
  • "amount": 0,
  • "strategy": "set_to",
  • "refillIntervalSeconds": 1,
  • "isActive": true,
  • "params": null
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Soft-disable a grant rule

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

List or substring-search users by callerId

Authorizations:
AdminKey
query Parameters
q
string
Example: q=5678

Optional substring match on users.callerid (LIKE %q%). Empty/omitted lists all active users. SQL LIKE wildcards in the input (%, _, \) are escaped server-side and matched literally.

limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Get all allowance balances for a user

Authorizations:
AdminKey
path Parameters
callerId
required
string <= 20 characters

Phone-format caller id (users.callerid), ≤20 chars.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Admin grant (bypasses tier enforcement)

Writes directly to user_allowances.balance and user_allowance_history. Idempotent on referenceId — re-sending the same key returns the prior ledger row with replayed: true and strategy: "replay", with no mutation.

Authorizations:
AdminKey
path Parameters
callerId
required
string <= 20 characters

Phone-format caller id (users.callerid), ≤20 chars.

Request Body schema: application/json
required
itemKey
required
string
amount
required
integer >= 0
strategy
string
Default: "set_to"
Enum: "set_to" "add" "set_to_at_least"
referenceId
string

Idempotency key. Server generates one when omitted, but callers are expected to provide a stable key — replays return the prior ledger row with replayed: true and no mutation.

object

Responses

Request samples

Content type
application/json
{
  • "itemKey": "string",
  • "amount": 0,
  • "strategy": "set_to",
  • "referenceId": "string",
  • "metadata": { }
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Admin debit (force-decrement)

Fails with 409 INVENTORY_EMPTY when balance would go below zero. Idempotent on referenceId. If the user's tier is unlimited for this item, returns {unlimited: true, balance: null} with no mutation.

Authorizations:
AdminKey
path Parameters
callerId
required
string <= 20 characters

Phone-format caller id (users.callerid), ≤20 chars.

Request Body schema: application/json
required
itemKey
required
string
amount
required
integer >= 1
referenceId
string
object

Responses

Request samples

Content type
application/json
{
  • "itemKey": "string",
  • "amount": 1,
  • "referenceId": "string",
  • "metadata": { }
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Query the allowance history ledger

Authorizations:
AdminKey
query Parameters
callerId
string
Example: callerId=905551112233

Exact match.

itemKey
string
Example: itemKey=free_seat

Exact match.

source
string
Enum: "grant" "consume" "expire" "refill" "admin" "refund"
Example: source=consume
from
string
Example: from=2026-05-01 00:00:00

Lower bound on created_atYYYY-MM-DD HH:MM:SS.

to
string
Example: to=2026-05-05 23:59:59

Upper bound on created_atYYYY-MM-DD HH:MM:SS.

limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

admin-avatars

Admin avatar catalog CRUD + tier rules

List avatars (paginated, filterable)

Lists rows from the avatars catalog. All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key).

Authorizations:
AdminKey
query Parameters
gender
string
Example: gender=female

Filter by gender (male, female, other).

usable
string
Example: usable=true

true/false to filter by is_usable. Tri-state — omit to get all.

listed
string
Example: listed=true

true/false to filter by is_listed. Tri-state — omit to get all.

active
string
Example: active=true

Deprecated alias for usable. Older admin-panel deeplinks still send this.

limit
integer
Default: 30
Example: limit=30
offset
integer
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Create a new avatar entry

Accepts optional tier (one of common|rare|epic|legendary, defaults to common) and is_default (boolean — at most one avatar may be marked as the system-wide default; UNIQUE).

Authorizations:
AdminKey
Request Body schema: application/json
required
displayName
string

User-facing label, 1–100 characters.

name
string

Legacy alias for displayName (1–100 characters). Send either name or displayName.

internalName
string

Optional stable ops identifier, 1–100 characters. Must be globally unique (DUPLICATE_INTERNAL_NAME on conflict). When omitted/blank it defaults to displayName.

gender
string
Enum: "male" "female" "other"
price
integer >= 0
tier
string
Default: "common"
Enum: "common" "rare" "epic" "legendary"
is_default
boolean

Mark this avatar as the single system-wide default. Only one row may hold this flag.

is_usable
integer
Enum: 0 1

Whether the avatar may be used by anyone (renamed from is_active).

is_listed
integer
Enum: 0 1

Whether the avatar appears in shop listings. Owners can still wear an unlisted avatar.

sort_order
integer
property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "displayName": "string",
  • "name": "string",
  • "internalName": "string",
  • "gender": "male",
  • "price": 0,
  • "tier": "common",
  • "is_default": true,
  • "is_usable": 0,
  • "is_listed": 0,
  • "sort_order": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read avatar tier-policy rules

Returns the rules object used by the mobile client to gate avatar selection.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read a single avatar by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update an avatar (POST instead of PUT — legacy admin clients)

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
displayName
string

User-facing label, 1–100 characters.

name
string

Legacy alias for displayName.

internalName
string

Stable ops identifier, 1–100 characters. Must be globally unique (DUPLICATE_INTERNAL_NAME on conflict).

property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "displayName": "string",
  • "name": "string",
  • "internalName": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Delete an avatar

Hard-delete. The service migrates every active user (user_profiles.avatar_id pointing here) onto the system default avatar before the row is removed; the FK on user_avatars is ON DELETE CASCADE, so ownership rows for this avatar disappear as well.

Rejected with:

  • AVATAR_IS_DEFAULT when the avatar is currently flagged is_default=1. Reassign the default to another avatar first, then retry.
  • NO_DEFAULT_AVAILABLE when active users currently wear this avatar AND the system has no other usable avatar to migrate them to. The admin panel should surface this as an explanatory dialog asking the operator to mark another avatar as default before retrying.
Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-interests

Admin interest catalog CRUD + icon image upload

List interests (paginated, filterable, incl. inactive)

Lists rows from the interests catalog with localized names. All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key).

Authorizations:
AdminKey
query Parameters
active
string
Example: active=true

true/false to filter by is_active. Tri-state — omit to get all.

q
string

Substring match against code and name.

limit
integer
Default: 30
Example: limit=30
offset
integer
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Create a new interest (multipart; image required)

Creates an interest with a served icon image. image is required. names is a JSON object of localized display names; interests.name is the English-canonical fallback. The legacy emoji icon is optional.

Authorizations:
AdminKey
Request Body schema: multipart/form-data
required
code
required
string

1–50 chars, [a-z0-9_]+, unique.

name
required
string

1–100 characters (canonical/en fallback).

icon
string

Optional legacy emoji fallback.

isActive
boolean
Default: true
sortOrder
integer
Default: 0
names
string

JSON map of localized names, e.g. {"tr":"Yemek","en":"Food"}. Allowed locales: tr, en.

image
required
string <binary>

Icon image file (png/jpeg/webp, ≤2 MB, ≤512×512 by default).

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read icon upload rules

Returns the icon upload constraints (max bytes, dimensions, allowed mime types/extensions).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Atomically persist the full catalog ordering

Body must contain EVERY interest id exactly once; position i maps to sort_order = (i+1)*10. A missing/extra/duplicate/unknown id is rejected with 400 INVALID_ORDER and nothing is written (the panel always submits the complete grid order, so divergence means a stale client).

Authorizations:
AdminKey
Request Body schema: application/json
required
order
required
Array of integers

Responses

Request samples

Content type
application/json
{
  • "order": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read a single interest by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update an interest (multipart; partial; POST instead of PUT — legacy admin clients)

Partial update. Supplying a new image replaces the icon and deletes the previous file. Supplying names upserts those locales.

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: multipart/form-data
code
string
name
string
icon
string
isActive
boolean
sortOrder
integer
names
string

JSON map of localized names.

image
string <binary>

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Hard-delete an interest (guarded; ?force=1 to override)

Guarded hard-delete. When the interest has user selections (user_profile_interests rows), the request is refused with 409 INTEREST_IN_USE carrying details.selectionCount, unless ?force=1 is supplied. With force (or when unused), FK ON DELETE CASCADE removes the interest from every user's selections (user_profile_interests, user_interests) and its interest_localized_info rows. To retire an interest without dropping user selections, set isActive=false via update instead. The icon file is removed (the shared _placeholder.png is never deleted).

Authorizations:
AdminKey
path Parameters
id
required
integer
query Parameters
force
string
Value: "1"

Required to delete an interest that users have selected. Without force the request returns 409 INTEREST_IN_USE.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-calls

Live 1:1 call presence (counts + participant list)

Instantaneous count of users currently in a 1:1 call, plus the list of active calls, for the monitoring panel (tp_panel). The source of truth is the Ratchet call daemon (websocket_server.php): every call_presence_publish_seconds seconds it builds a full snapshot from the in-memory live calls and writes it to the call:live:snapshot Redis key (with a call_presence_ttl_seconds TTL); this endpoint reads that key. A snapshot that exists but is empty (calls: []) is a healthy zero; if the key is absent (daemon down / Redis error) the response returns stale: true. Participants are counted per session (not per socket), because the callee subscribes with the session UUID only. synthetic is always 0 — synthetics are blocked from calls at startCall. When call_presence_enabled = false all counts return zero. Authenticated with X-Admin-Key (ADMIN_API_KEY). Participant identities are PII, so this endpoint is admin-key only.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "activeCalls": 12,
  • "callersInCalls": 24,
  • "real": 22,
  • "dev": 2,
  • "synthetic": 0,
  • "asOfMs": 1719100000000,
  • "stale": false,
  • "calls": [
    ]
}

admin-horoscopes

Admin horoscope (zodiac) catalog — icon/name update only; fixed 12-sign catalog (no create/delete/reorder)

List horoscopes (paginated, filterable, incl. inactive)

Lists rows from the fixed 12-sign horoscope catalog with localized names. All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key). The catalog is fixed — there is no create/delete/reorder.

Authorizations:
AdminKey
query Parameters
active
string
Example: active=true

true/false to filter by is_active. Tri-state — omit to get all.

q
string

Substring match against code and name.

limit
integer
Default: 30
Example: limit=30
offset
integer
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read icon upload rules

Returns the icon upload constraints (max bytes, dimensions, allowed mime types/extensions).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read a single horoscope by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update a horoscope (multipart; partial; POST instead of PUT — legacy admin clients)

Partial update of icon/name/localized names/active flag/sort order. Supplying a new image replaces the icon and deletes the previous file (the shared _placeholder.png is never deleted). Supplying names upserts those locales. There is no create/delete — the 12 signs are fixed.

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: multipart/form-data
code
string
name
string
icon
string
isActive
boolean
sortOrder
integer
names
string

JSON map of localized names, e.g. {"tr":"Aslan","en":"Leo"}. Allowed locales: tr, en.

image
string <binary>

Icon image file (png/jpeg/webp/svg, ≤2 MB, ≤512×512 by default).

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-cosmetics

Admin cosmetics catalog CRUD per type (frames, backgrounds, banners) — image + SVGA animation upload rules, guarded delete, reorder, label assignment

List cosmetic items of a type (paginated, searchable, incl. unlisted/unusable)

Lists every cosmetic_items row of the type. All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key). Unknown types ⇒ 404 UNKNOWN_COSMETIC_TYPE.

Authorizations:
AdminKey
path Parameters
type
required
string
Example: game_background

Registry-resolved cosmetic type. NOT a fixed enum: the static types (frame, avatar, banner) plus every background_types.type_key row (background, game_background, voiceroom_background, …). Resolution is catalog-first. Unknown ⇒ 404 UNKNOWN_COSMETIC_TYPE.

query Parameters
q
string

Substring match against name.

limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Create a cosmetic item (multipart; image required)

Creates an item of the type. The image is validated against the type's effective rules (mime, byte cap, dimensions, aspect ratio — see /admin/cosmetics/{type}/rules). Frames reject JPEG structurally (no alpha channel). isDefault: true atomically swaps the per-type default.

This is also the endpoint that creates a background item — pass the background area as {type} (any background_types.type_key). Creating a background area is a different endpoint entirely: POST /admin/background-types.

The response is NOT shaped by create() itself — it re-reads the row via get()shape(). Three consequences worth knowing before you code against it:

  • labels comes back [] even when the request assigned labels (they are persisted; shape() just never populates the field on this path). Re-fetch via GET /admin/cosmetics/{type}/{id} to read them.
  • displayName is resolved at the default locale (en) — the create response ignores ?locale= / Accept-Language. Read displayNames for other locales.
  • attrs is null and gender is absent for background areas (catalog areas force allowed_attrs_keys = []).

On any failure after the file lands on disk (DB insert, spine provisioning) the uploaded image and animation are deleted before the error propagates — no orphan files.

Authorizations:
AdminKey
path Parameters
type
required
string
Example: game_background

Registry-resolved cosmetic type. NOT a fixed enum: the static types (frame, avatar, banner) plus every background_types.type_key row. Unknown ⇒ 404 UNKNOWN_COSMETIC_TYPE.

Request Body schema: multipart/form-data
required
displayName
string <= 100 characters

OPTIONAL user-facing label, up to 100 characters. Blank/absent stores NULL (untitled item) and reads emit displayName: null. Sets the display_name mirror.

name
required
string <= 100 characters

Legacy alias for displayName (also optional). Send either name or displayName.

displayNames
string

JSON string locale map, e.g. {"tr":"Altın Çerçeve","en":"Gold Ring"}. When present, authoritative; displayName / name mirrors are set from displayNames[en]. When absent, both locales are set to displayName. An empty or all-blank map is legal and stores NULL (untitled item). Send as a JSON-encoded string field in multipart; object in JSON body.

internalName
string [ 1 .. 100 ] characters

REQUIRED stable ops identifier, 1–100 characters. Must be globally unique (DUPLICATE_INTERNAL_NAME on conflict). It is no longer derived from displayName — the title is optional, so a blank internalName is INVALID_INTERNAL_NAME. It is also the label clients render for untitled items.

price
integer >= 0
Default: 0
tier
string
Default: "common"
Enum: "common" "rare" "epic" "legendary"
isDefault
boolean
Default: false
isUsable
boolean
Default: true
isListed
boolean
Default: true
sortOrder
integer
Default: 0
attrs
string

JSON object; only the type's registry-allowed keys (Phase 1 types: none — must be empty/absent).

labels
string

JSON string array of shop-label keys to assign to this item's spine row on creation, e.g. ["featured","new"]. Send as a JSON-encoded string in multipart.

force
boolean
Default: false

When true, bypasses SOFT image limits (byte cap, dimension caps, mime allowlist). HARD absolute ceilings are never bypassed. Use after a details.overridable: true rejection.

image
required
string <binary>

Item image; constraints per type (see rules endpoint). SOFT rejections return details.overridable: true; retry with force: true. HARD rejections return details.overridable: false.

animation
string <binary>

Optional SVGA animation file (v1 zip or v2 zlib container).

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Read the effective upload rules of a type

Registry defaults merged with system_config overrides — the panel reads these for client-side upload guardrails.

Authorizations:
AdminKey
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Atomically persist the full ordering of one type

Body must contain EVERY item id of the type exactly once; position i maps to sort_order = (i+1)*10 in a single UPDATE … CASE. A missing/extra/duplicate/unknown id is rejected with 400 INVALID_ORDER and nothing is written. Other types are untouched.

Authorizations:
AdminKey
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

Request Body schema: application/json
required
order
required
Array of integers

Responses

Request samples

Content type
application/json
{
  • "order": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read a single cosmetic item by id

Authorizations:
AdminKey
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update a cosmetic item (multipart; partial; POST instead of PUT — legacy admin clients)

Partial update. Supplying a new image replaces the file and deletes the previous one. isDefault: true atomically swaps the per-type default; un-setting the default of a requires_default type is refused with 409 DEFAULT_REQUIRED (Phase 2 avatars).

Authorizations:
AdminKey
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

id
required
integer
Request Body schema: multipart/form-data
displayName
string <= 100 characters

User-facing label, up to 100 characters. Updates the display_name mirror; sending a blank value CLEARS the title (drops the en entry from display_names and stores NULL when no locale survives).

name
string <= 100 characters

Legacy alias for displayName.

displayNames
string

JSON string locale map, e.g. {"tr":"Altın Çerçeve","en":"Gold Ring"}. When present, sets display_names and updates display_name from displayNames[en]. An empty or all-blank map CLEARS the title (both columns become NULL) — this is the panel's clear path, not a validation error. When only displayName/name is updated, display_names[en] is kept in sync automatically.

internalName
string

Stable ops identifier, 1–100 characters. Must be globally unique (DUPLICATE_INTERNAL_NAME on conflict).

price
integer >= 0
tier
string
Enum: "common" "rare" "epic" "legendary"
isDefault
boolean
isUsable
boolean
isListed
boolean
sortOrder
integer
attrs
string

JSON object; registry-allowed keys only.

force
boolean
Default: false

Bypass SOFT image limits. HARD ceilings still apply.

image
string <binary>

New image replaces + deletes old. SOFT rejections return details.overridable:true; retry with force:true.

animation
string <binary>

Optional SVGA animation file (v1 zip or v2 zlib container). Replaces + deletes the old one.

animationUrl
string

Update only: send empty string to clear the stored animation.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Hard-delete a cosmetic item (guarded; ?force=1 to override)

Guarded hard delete. When the item has owners (user_cosmetic_items) or active selectors (user_cosmetic_selections), the request is refused with 409 COSMETIC_IN_USE carrying details.ownerCount + details.activeCount, unless ?force=1 is supplied. With force (or when unused), FK ON DELETE CASCADE removes ownership + selection rows and the image file is deleted. For requires_default types (Phase 2 avatars) force-delete migrates active selectors to the type default first; deleting the default itself is refused (409 COSMETIC_IS_DEFAULT). To retire an item without dropping ownership, set isUsable=false via update.

Authorizations:
AdminKey
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

id
required
integer
query Parameters
force
string
Value: "1"

Required to delete an item that is owned or selected. Without force the request returns 409 COSMETIC_IN_USE.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Reassign a background item to another area (guarded; ?force=1)

Moves a background item from one area to another (e.g. game_backgroundvoiceroom_background). Reassign is background-area ↔ background-area only — both {type} (source) and targetType must be background_types catalog keys; a non-background or cross-family target is refused with 400 INVALID_MOVE_TARGET.

The user_cosmetic_selections composite FK (type, cosmetic_item_id) is ON UPDATE RESTRICT, so an item that users have equipped cannot be moved directly: the request is refused with 409 COSMETIC_HAS_SELECTORS (details.activeCount) unless ?force=1, which clears those selections (equipped users lose that background and re-select) and then moves. Ownership (user_cosmetic_items) and the shop_items spine row are keyed on the item id only, so they are preserved — owners keep the item, now in the new area. Data-only (no image re-upload / re-validation).

Authorizations:
AdminKey
path Parameters
type
required
string

Source area (a background_types key).

id
required
integer
query Parameters
force
string
Value: "1"

Clear equipped selections and move (required when the item has active selectors).

Request Body schema: application/json
required
targetType
required
string

Destination area (a background_types key).

Responses

Request samples

Content type
application/json
{
  • "targetType": "voiceroom_background"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Assign / remove labels on a cosmetic item (by cosmetic id — resolves to shop_item spine)

Convenience alias on the admin cosmetics surface. Resolves the cosmetic_items.idshop_items spine via ShopItemRepository::findByCosmeticId, then delegates to LabelService::assignToShopItem. Returns 404 SHOP_ITEM_NOT_FOUND if the cosmetic has no spine row yet (create the shop_item first).

Authorizations:
AdminKey
path Parameters
type
required
string
Example: game_background

Registry-resolved type: frame/avatar/banner plus every background_types.type_key (background, game_background, …). Not a fixed enum.

id
required
integer >= 1
Request Body schema: application/json
required
add
Array of strings

Label keys to add.

remove
Array of strings

Label keys to remove.

Responses

Request samples

Content type
application/json
{
  • "add": [
    ],
  • "remove": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-background-types

Admin catalog of dynamic background areas (chat / game / voice-room / profile / …). Each row here IS a cosmetic type; this surface manages the areas and their config (orientation, dimension/ratio bounds + caps, mime, SVGA), with guarded delete (BACKGROUND_TYPE_IN_USE when items exist; isActive=false to retire) and reorder. The items inside each area are managed through the generic admin-cosmetics surface with the area key as {type}, and an item can be reassigned between areas via POST /admin/cosmetics/{type}/{id}/move. The app-facing config lives at GET /v1/backgrounds/types.

List background types (paginated, searchable)

Authorizations:
AdminKey
query Parameters
q
string

Substring match against type_key or display name.

limit
integer [ 1 .. 200 ]
Default: 50
offset
integer
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Create a background type

Creates a background area. typeKey must be unique and slug-safe (^[a-z0-9_]{1,64}$) — it becomes the cosmetic type. orientation is landscape or portrait. displayNames is a locale-keyed map. configPrefix/uploadDirSegment default from typeKey when omitted; dimension/ratio/byte/animation caps default when omitted.

Authorizations:
AdminKey
Request Body schema: application/json
required
typeKey
required
string
required
object
orientation
required
string
Enum: "landscape" "portrait"
configPrefix
string
uploadDirSegment
string
minWidth
integer
minHeight
integer
maxWidth
integer
maxHeight
integer
aspectMin
number
aspectMax
number
maxBytes
integer
absMaxBytes
integer
absMaxWidth
integer
absMaxHeight
integer
object

mime→ext map; defaults to jpeg/png/webp.

animationAllowed
boolean
animationMaxBytes
integer
animationAbsMaxBytes
integer
sortOrder
integer
isActive
boolean

Responses

Request samples

Content type
application/json
{
  • "typeKey": "game_background",
  • "displayNames": {
    },
  • "orientation": "landscape",
  • "configPrefix": "string",
  • "uploadDirSegment": "string",
  • "minWidth": 0,
  • "minHeight": 0,
  • "maxWidth": 0,
  • "maxHeight": 0,
  • "aspectMin": 0,
  • "aspectMax": 0,
  • "maxBytes": 0,
  • "absMaxBytes": 0,
  • "absMaxWidth": 0,
  • "absMaxHeight": 0,
  • "allowedMimeTypes": {
    },
  • "animationAllowed": true,
  • "animationMaxBytes": 0,
  • "animationAbsMaxBytes": 0,
  • "sortOrder": 0,
  • "isActive": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Reorder background types (full-set)

Authorizations:
AdminKey
Request Body schema: application/json
required
order
required
Array of integers

Full ordered list of background_types IDs.

Responses

Request samples

Content type
application/json
{
  • "order": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Get a single background type

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Update a background type (partial)

Partial update. typeKey is immutable (not accepted).

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
object
orientation
string
Enum: "landscape" "portrait"
configPrefix
string or null
uploadDirSegment
string or null
minWidth
integer or null
minHeight
integer or null
maxWidth
integer or null
maxHeight
integer or null
aspectMin
number or null
aspectMax
number or null
maxBytes
integer
absMaxBytes
integer
absMaxWidth
integer
absMaxHeight
integer
object
animationAllowed
boolean
animationMaxBytes
integer
animationAbsMaxBytes
integer
sortOrder
integer
isActive
boolean

Responses

Request samples

Content type
application/json
{
  • "displayNames": {
    },
  • "orientation": "landscape",
  • "configPrefix": "string",
  • "uploadDirSegment": "string",
  • "minWidth": 0,
  • "minHeight": 0,
  • "maxWidth": 0,
  • "maxHeight": 0,
  • "aspectMin": 0,
  • "aspectMax": 0,
  • "maxBytes": 0,
  • "absMaxBytes": 0,
  • "absMaxWidth": 0,
  • "absMaxHeight": 0,
  • "allowedMimeTypes": {
    },
  • "animationAllowed": true,
  • "animationMaxBytes": 0,
  • "animationAbsMaxBytes": 0,
  • "sortOrder": 0,
  • "isActive": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Delete a background type (guarded)

Hard delete. Succeeds only when no cosmetic_items of the type exist — otherwise 409 BACKGROUND_TYPE_IN_USE (details.itemCount). There is no ?force=1 bypass. Deleting the grandfathered background area is refused (BACKGROUND_TYPE_PROTECTED). To retire an area with live items, set isActive=false instead.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-media-constraints

One read/write surface for every media upload constraint in the backend — byte caps, pixel bounds, aspect windows and mime allowlists per slot (cosmetic frame/avatar/banner/background images + SVGA, gifts, quest art, shop icons, interest/horoscope icons, and one image + one animation slot per background_types row). Every field reports default / effective / overridden, so the panel can show what a limit is and where it came from. Overrides persist to system_config for prefix-backed slots and to background_types columns for catalog slots; a DELETE is reset-to-default.

List every configurable media slot, grouped by surface

Enumerates every upload slot the panel can configure (cosmetics, gifts, quests, shop icons, interest/horoscope icons, plus one image + one animation slot per background_types row) alongside the PHP transport ceilings the app process is actually running under.

Each field reports a triple: default (baked into the registry), effective (what validation will use) and overridden.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Read one media slot

Authorizations:
AdminKey
path Parameters
slot
required
string^[A-Za-z0-9._-]+$
Example: gift.image

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Override one or more constraint fields on a slot

Partial override. Invariants are checked against the merged slot (submitted values on top of what is already effective), because lowering a hard ceiling can invalidate a soft cap the body never mentions.

The absMaxBytes transport check applies only when absMaxBytes is submitted — a baked-in default above the deployment's upload_max_filesize is not something the operator chose.

Overrides land in system_config for prefix-backed slots and in background_types columns for catalog slots.

Authorizations:
AdminKey
path Parameters
slot
required
string^[A-Za-z0-9._-]+$
Example: gift.image
header Parameters
X-Admin-Actor
string <= 100 characters

Recorded as updated_by; defaults to admin.

Request Body schema: application/json
required
non-empty
maxBytes
integer >= 1
absMaxBytes
integer >= 1
minWidth
integer >= 1
minHeight
integer >= 1
maxWidth
integer >= 1
maxHeight
integer >= 1
absMaxWidth
integer >= 1
absMaxHeight
integer >= 1
aspectMin
number > 0
aspectMax
number > 0
allowedMimeTypes
Array of strings non-empty
Items Enum: "image/png" "image/jpeg" "image/webp" "image/svg+xml"

Responses

Request samples

Content type
application/json
{
  • "maxBytes": 1,
  • "absMaxBytes": 1,
  • "minWidth": 1,
  • "minHeight": 1,
  • "maxWidth": 1,
  • "maxHeight": 1,
  • "absMaxWidth": 1,
  • "absMaxHeight": 1,
  • "aspectMin": 0,
  • "aspectMax": 0,
  • "allowedMimeTypes": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Reset every field on a slot back to its default

Clears all overrides for the slot. For prefix-backed slots this deletes the system_config rows; for background_types slots the columns are NOT NULL, so the registry default is written back instead.

Authorizations:
AdminKey
path Parameters
slot
required
string^[A-Za-z0-9._-]+$
Example: gift.image
header Parameters
X-Admin-Actor
string <= 100 characters

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Reset a single field back to its default

Authorizations:
AdminKey
path Parameters
slot
required
string^[A-Za-z0-9._-]+$
Example: gift.image
field
required
string (MediaConstraintFieldName)
Enum: "maxBytes" "absMaxBytes" "minWidth" "minHeight" "maxWidth" "maxHeight" "absMaxWidth" "absMaxHeight" "aspectMin" "aspectMax" "allowedMimeTypes"

A configurable constraint field. Image slots accept all eleven; animation slots accept only maxBytes and absMaxBytes.

header Parameters
X-Admin-Actor
string <= 100 characters

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

admin-labels

Cosmetic label CRUD — create, list, update, reorder, guarded delete (force flag for in-use labels)

List cosmetic labels (paginated, searchable)

Authorizations:
AdminKey
query Parameters
q
string

Substring match against key or display name.

limit
integer [ 1 .. 200 ]
Default: 50
offset
integer
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Create a label

Creates a cosmetic label. key must be unique (slug-safe, 1–60 chars). kind is one of manual, computed, both. displayNames is a locale-keyed map (tr, en, etc.). Optional placement (badge, chip, both) and computeRule (string, required when kind is computed or both).

Authorizations:
AdminKey
Request Body schema: application/json
required
key
required
string
required
object
kind
required
string
Enum: "manual" "computed" "both"
placement
string
Enum: "badge" "chip" "both"
computeRule
string
iconUrl
string or null

Stored webp/svg icon URL (from POST /admin/shop/uploads/label). Preferred over iconEmoji.

iconEmoji
string or null

Emoji fallback, shown when iconUrl is null.

Responses

Request samples

Content type
application/json
{
  • "key": "featured",
  • "displayNames": {
    },
  • "kind": "manual",
  • "placement": "badge",
  • "computeRule": "string",
  • "iconUrl": "/uploads/shop-labels/1_abc.webp",
  • "iconEmoji": "🆕"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Reorder labels

Authorizations:
AdminKey
Request Body schema: application/json
required
order
required
Array of integers

Ordered list of label IDs.

Responses

Request samples

Content type
application/json
{
  • "order": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Get a single label

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": {
    },
  • "error": "string"
}

Update a label

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
key
string
object
kind
string
Enum: "manual" "computed" "both"
placement
string
Enum: "badge" "chip" "both"
computeRule
string
iconUrl
string or null

Stored webp/svg icon URL (from POST /admin/shop/uploads/label). Preferred over iconEmoji.

iconEmoji
string or null

Emoji fallback, shown when iconUrl is null.

Responses

Request samples

Content type
application/json
{
  • "key": "string",
  • "displayNames": {
    },
  • "kind": "manual",
  • "placement": "badge",
  • "computeRule": "string",
  • "iconUrl": "string",
  • "iconEmoji": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Delete a label

Deletes the label. If the label is assigned to any shop items, returns 409 LABEL_IN_USE with details.assignedCount. Pass ?force=1 (or true/yes/on) to force-delete and cascade-remove all assignments.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
query Parameters
force
string
Enum: "1" "true" "yes" "on"

Force-delete even if label is in use.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-gifts

Gift catalog CRUD — image upload rules, guarded delete, reorder, label assignment

List gift catalog rows (paginated, searchable, incl. inactive/unlisted)

Lists every gifts row including inactive and unlisted entries. All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).

Authorizations:
AdminKey
query Parameters
q
string

Substring match against display_name or internal_name.

limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Create a gift (multipart; image optional)

Creates a new gift row and auto-provisions its shop_items spine row (kind=gift). The spine row enables ?label= filtering and unified buy. internalName defaults to displayName when blank — collision on either raises DUPLICATE_INTERNAL_NAME (409).

Authorizations:
AdminKey
Request Body schema: multipart/form-data
required
displayName
required
string

User-facing label, 1–150 characters. Sets the display_name mirror.

displayNames
string

JSON string locale map, e.g. {"tr":"Gül","en":"Rose"}. When present, authoritative; displayName is set from displayNames[en]. When absent, both locales are set to displayName. Parsed from the multipart string by the controller; send as a JSON-encoded string field. An object body (non-multipart) can send an actual object here instead.

internalName
string

Stable ops identifier, 1–120 characters. Must be globally unique. When omitted/blank defaults to displayName.

price
required
integer >= 0
Default: 0

Unit coin cost.

tier
string or null
Enum: "common" "rare" "epic" "legendary"

Display-only tier key. Unknown values render no badge.

isActive
boolean
Default: true

When false: not giftable or buyable.

isListed
boolean
Default: true

When false: hidden from the app catalog.

sortOrder
integer
Default: 0
labels
string

JSON string array of shop-label keys to assign to this gift's spine row on creation, e.g. ["featured","new"]. Parsed from the multipart string by the controller.

force
boolean
Default: false

When true, bypasses SOFT image limits (max_bytes, max_width, max_height, mime allowlist). HARD absolute ceilings (abs_max_bytes/abs_max_width/abs_max_height) are never bypassed. Use after a details.overridable: true rejection.

image
string <binary>

Gift image. SOFT constraints per gift_image_* system_config keys (see /admin/gifts/rules). Raster only (WebP/PNG/JPEG). SOFT rejections return details.overridable: true; retry with force: true. HARD rejections return details.overridable: false.

animation
string <binary>

Optional SVGA animation file (v1 zip or v2 zlib container).

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read the effective image-upload rules for gifts

Returns the effective upload rules derived from system_config gift_image_* keys merged with baked-in defaults. The panel reads this to drive client-side upload guardrails before submitting a create/update.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Atomically reorder the full gift catalog

Body must contain EVERY gift id exactly once; missing, extra, duplicate, or unknown ids are rejected with 400 INVALID_ORDER and nothing is written.

Authorizations:
AdminKey
Request Body schema: application/json
required
order
required
Array of integers

Complete ordered list of all gift ids.

Responses

Request samples

Content type
application/json
{
  • "order": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read a single gift by id

Authorizations:
AdminKey
path Parameters
id
required
integer
Example: 7

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update a gift (multipart; partial; POST instead of PUT — legacy admin clients)

Partial update. Only supplied fields are changed. Sending a new image replaces the file and deletes the old one from storage. Setting imageUrl to an empty string explicitly clears the image without uploading a replacement.

Authorizations:
AdminKey
path Parameters
id
required
integer
Example: 7
Request Body schema: multipart/form-data
displayName
string

User-facing label, 1–150 characters. Updates display_name mirror.

displayNames
string

JSON string locale map, e.g. {"tr":"Gül","en":"Rose"}. When present, sets both display_names and updates display_name from displayNames[en]. Send as a JSON-encoded string in multipart.

internalName
string

Stable ops identifier, 1–120 characters. Must be globally unique.

price
integer >= 0
tier
string or null
Enum: "common" "rare" "epic" "legendary"
isActive
boolean
isListed
boolean
sortOrder
integer
imageUrl
string

Set to empty string to clear the image.

force
boolean
Default: false

When true, bypasses SOFT image limits. HARD absolute ceilings are never bypassed. Use after a details.overridable: true rejection.

image
string <binary>

New image replaces + deletes the old one. SOFT rejections return details.overridable:true; retry with force:true.

animation
string <binary>

Optional SVGA animation file (v1 zip or v2 zlib container). Replaces + deletes the old one.

animationUrl
string

Update only: send empty string to clear the stored animation.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Hard-delete a gift (guarded; ?force=1 to override)

Guarded hard delete. When any user owns the gift (user_gift_inventory.quantity > 0) or has received it (user_gift_received.quantity > 0), the request is refused with 409 GIFT_IN_USE carrying details.ownerCount and details.receivedCount, unless ?force=1 is supplied. With force, FK ON DELETE CASCADE removes inventory, received, and ledger rows; the image file is also deleted. To retire a gift without dropping inventory, set isActive=false via update.

Authorizations:
AdminKey
path Parameters
id
required
integer
Example: 7
query Parameters
force
string
Value: "1"

Required to delete a gift that is owned or has been received. Without force the request returns 409 GIFT_IN_USE.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Assign or remove shop labels from a gift's spine row

Resolves the gift's shop_items spine row and delegates to LabelService::assignToShopItem. The add and remove arrays accept label keys; unknown or non-forcible keys are rejected. Manual assignment of computed-only labels is refused with 400 LABEL_NOT_FORCIBLE.

Authorizations:
AdminKey
path Parameters
id
required
integer
Example: 7
Request Body schema: application/json
required
add
Array of strings

Label keys to assign.

remove
Array of strings

Label keys to remove.

Responses

Request samples

Content type
application/json
{
  • "add": [
    ],
  • "remove": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-shop

Shop item spine management — list, reorder, and label assignment by shopItemId

List shop items (spine rows wrapping cosmetics)

Returns the shop_items spine — one row per cosmetic item registered in the shop. Includes the cosmetic id, kind, sort_order, and associated label keys. All /admin/shop/* routes are JWT-exempt and guarded by X-Admin-Key.

Authorizations:
AdminKey
query Parameters
kind
string

Filter by item kind (e.g. frame, background).

q
string

Substring match against display name.

limit
integer [ 1 .. 200 ]
Default: 50
offset
integer
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Reorder shop items

Authorizations:
AdminKey
Request Body schema: application/json
required
order
required
Array of integers

Ordered list of shop_item IDs.

Responses

Request samples

Content type
application/json
{
  • "order": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Assign / remove labels on a shop item (by shopItemId)

Atomically adds and removes label assignments on the given shop_item. Labels are identified by key string (not id). Only manual or both-kind labels can be manually assigned; computed-only labels reject with 409 LABEL_NOT_FORCIBLE. Send empty arrays to no-op.

Authorizations:
AdminKey
path Parameters
shopItemId
required
integer >= 1
Request Body schema: application/json
required
add
Array of strings

Label keys to add.

remove
Array of strings

Label keys to remove.

Responses

Request samples

Content type
application/json
{
  • "add": [
    ],
  • "remove": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Upload rules for all four shop upload kinds

The effective validation rules each shop upload kind will apply, so the panel can gate a file client-side before spending the upload (a HARD rejection is never forceable). Each value is the ImageStorage::getRules() payload — maxBytes / absMaxBytes, maxWidth/maxHeight and their absolute ceilings, minWidth/ minHeight, the aspect window, and the allowed mime/extension lists. Registered before the {kind} placeholder route so rules is not swallowed as a kind. JWT-exempt, guarded by X-Admin-Key.

These are the same numbers /admin/media-constraints reports for the shop.* slots, and they are operator-configurable there.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Upload a shop image/icon and get its server-minted URL

One multipart upload endpoint for every shop image/icon. The operator never types a URL: the panel posts the file here, the server validates it (size / dimensions / mime against {prefix}_* system_config rules) and stores it under public/uploads/shop-{kind}s/, returning the minted relative URL plus the decoded dimensions. The panel then sends that imageUrl (and width/height for packages) to the existing JSON create/update endpoints. JWT-exempt, guarded by X-Admin-Key.

Authorizations:
AdminKey
path Parameters
kind
required
string
Enum: "package" "tier" "label" "atom"

Selects the storage segment + validation rules.

Request Body schema: multipart/form-data
required
image
required
string <binary>

The image file (webp/png/jpeg).

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

List shop atoms (coin / allowance / privilege sellables)

Non-cosmetic shop_items rows whose payload + localized display live in attrs. JWT-exempt, X-Admin-Key guarded.

Authorizations:
AdminKey
query Parameters
kind
string
Enum: "coin" "allowance" "privilege"
q
string

Substring match against attrs JSON.

limit
integer
Default: 50
offset
integer
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Create a shop atom

Per-kind payload — coin {amount}; allowance {allowanceKey, amount}; privilege {privilegeKey, durationDays} (fulfillment pending). All accept displayNames ({tr,en}) + iconUrl.

Authorizations:
AdminKey
Request Body schema: application/json
required
kind
required
string
Enum: "coin" "allowance" "privilege"
required
object
isListed
boolean
Default: false

Responses

Request samples

Content type
application/json
{
  • "kind": "coin",
  • "attrs": { },
  • "isListed": false
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Get a shop atom

Authorizations:
AdminKey
path Parameters
shopItemId
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update a shop atom (partial)

Authorizations:
AdminKey
path Parameters
shopItemId
required
integer >= 1
Request Body schema: application/json
required
object
isListed
boolean

Responses

Request samples

Content type
application/json
{
  • "attrs": { },
  • "isListed": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Delete a shop atom (guarded)

Rejects with 409 ATOM_IN_USE (+details.packageCount) when referenced by a package; ?force=1 cascades.

Authorizations:
AdminKey
path Parameters
shopItemId
required
integer >= 1
query Parameters
force
string
Value: "1"

Force-delete even if referenced.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

List shop packages

Authorizations:
AdminKey
query Parameters
q
string

Substring match against internal_name.

isActive
string
Enum: "1" "0"
limit
integer
Default: 50
offset
integer
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Create a shop package

Creates a shop_packages row + its kind='package' spine + contents. Validates layout/image/currency, runs the image-ratio soft-check (overridable with ?force=1), validates content refs, and snapshots full_price/discount_percent.

Authorizations:
AdminKey
query Parameters
force
string
Value: "1"

Bypass the image-ratio soft-check.

Request Body schema: application/json
required
internalName
required
string
object or null

Localized {tr,en}.

object or null
imageUrl
string or null
imageType
required
string
Enum: "tall" "square" "banner" "circle"
layoutType
required
string
Enum: "banner" "tall" "tile" "showcase"
fullBackground
boolean
Default: false
imageWidth
integer or null
imageHeight
integer or null
currency
required
string
Enum: "coin" "money"
price
integer or null
storeProductId
string or null
fullPrice
integer or null

Explicit compare-at; otherwise auto-snapshotted.

validFromMs
integer or null
validUntilMs
integer or null
repurchasable
boolean
Default: false
maxPerUser
integer or null
isActive
boolean
Default: true
required
Array of objects

Responses

Request samples

Content type
application/json
{
  • "internalName": "string",
  • "title": {
    },
  • "subtext": {
    },
  • "imageUrl": "string",
  • "imageType": "tall",
  • "layoutType": "banner",
  • "fullBackground": false,
  • "imageWidth": 0,
  • "imageHeight": 0,
  • "currency": "coin",
  • "price": 0,
  • "storeProductId": "string",
  • "fullPrice": 0,
  • "validFromMs": 0,
  • "validUntilMs": 0,
  • "repurchasable": false,
  • "maxPerUser": 0,
  • "isActive": true,
  • "contents": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Get a shop package (with resolved contents)

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update a shop package (partial; full-set contents replace)

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
query Parameters
force
string
Value: "1"

Bypass the image-ratio soft-check.

Request Body schema: application/json
required
internalName
required
string
object or null

Localized {tr,en}.

object or null
imageUrl
string or null
imageType
required
string
Enum: "tall" "square" "banner" "circle"
layoutType
required
string
Enum: "banner" "tall" "tile" "showcase"
fullBackground
boolean
Default: false
imageWidth
integer or null
imageHeight
integer or null
currency
required
string
Enum: "coin" "money"
price
integer or null
storeProductId
string or null
fullPrice
integer or null

Explicit compare-at; otherwise auto-snapshotted.

validFromMs
integer or null
validUntilMs
integer or null
repurchasable
boolean
Default: false
maxPerUser
integer or null
isActive
boolean
Default: true
required
Array of objects

Responses

Request samples

Content type
application/json
{
  • "internalName": "string",
  • "title": {
    },
  • "subtext": {
    },
  • "imageUrl": "string",
  • "imageType": "tall",
  • "layoutType": "banner",
  • "fullBackground": false,
  • "imageWidth": 0,
  • "imageHeight": 0,
  • "currency": "coin",
  • "price": 0,
  • "storeProductId": "string",
  • "fullPrice": 0,
  • "validFromMs": 0,
  • "validUntilMs": 0,
  • "repurchasable": false,
  • "maxPerUser": 0,
  • "isActive": true,
  • "contents": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Delete a shop package (cascades contents + spine row)

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

List item tiers (global tier registry)

Authorizations:
AdminKey
query Parameters
q
string
limit
integer
Default: 50
offset
integer
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Create an item tier

Authorizations:
AdminKey
Request Body schema: application/json
required
key
required
string^[a-z0-9_]{1,64}$
required
object
color
string or null
backgroundColor
string or null
icon
string or null
iconUrl
string or null
sortOrder
integer
Default: 0
isActive
boolean
Default: true

Responses

Request samples

Content type
application/json
{
  • "key": "string",
  • "displayNames": {
    },
  • "color": "string",
  • "backgroundColor": "string",
  • "icon": "string",
  • "iconUrl": "string",
  • "sortOrder": 0,
  • "isActive": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Reorder item tiers (atomic full-set)

Authorizations:
AdminKey
Request Body schema: application/json
required
order
required
Array of integers

Responses

Request samples

Content type
application/json
{
  • "order": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Get an item tier

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update an item tier (partial)

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
key
required
string^[a-z0-9_]{1,64}$
required
object
color
string or null
backgroundColor
string or null
icon
string or null
iconUrl
string or null
sortOrder
integer
Default: 0
isActive
boolean
Default: true

Responses

Request samples

Content type
application/json
{
  • "key": "string",
  • "displayNames": {
    },
  • "color": "string",
  • "backgroundColor": "string",
  • "icon": "string",
  • "iconUrl": "string",
  • "sortOrder": 0,
  • "isActive": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Delete an item tier (guarded)

Rejects with 409 TIER_IN_USE (+details.assignedCount) when cosmetics reference the tier key; ?force=1 deletes anyway (display metadata only — item tier strings are unaffected).

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
query Parameters
force
string
Value: "1"

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-coin

Admin coin operations — RevenueCat balance/transactions + MySQL↔RC sync report

Read a user's coin balance from RevenueCat

Looks up the live virtual-currency balance from RevenueCat (admin operations + cross-system reconciliation). All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).

Authorizations:
AdminKey
query Parameters
appUserId
required
string
Example: appUserId=905551112233

RevenueCat app user id (users.callerid).

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

List a user's RevenueCat virtual-currency transactions

Authorizations:
AdminKey
query Parameters
appUserId
required
string
Example: appUserId=905551112233
limit
integer
Default: 50
Example: limit=50

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

MySQL ↔ RevenueCat reconciliation report

Returns drift between user_wallet_summary (MySQL source of truth) and RC's reported balance for the queried user(s). Used to debug coin-system inconsistencies — see .claude/plans/wallet-system.md.

Authorizations:
AdminKey
query Parameters
appUserId
string
Example: appUserId=905551112233

Optional — when omitted, returns a global report (slow).

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Paginated unified `coin_history` ledger view (admin panel)

Backs the admin-panel "Coin History" page. Reads coin_history joined with users.display_name, decorates each row with derived sourceTag / environmentTag / statusTag (so the panel doesn't reimplement the decoder), and returns top-strip KPIs in the same payload to keep auto-poll to one round-trip.

The two label axes:

  • sourceTagiap | refund | spend | reward | gift | admin | transfer | unknown. Reads metadata.source; falls back to coin_history.type for pre-tagging-era rows (type='purchase'iap).
  • environmentTagPROD | SANDBOX | TEST. Reads metadata.environment then metadata.rc_event.purchase_environment; missing means PROD (treats pre-tagging rows as production).
Authorizations:
AdminKey
query Parameters
callerId
string
Example: callerId=905551112233

Exact match on coin_history.phone_number.

source
string
Enum: "iap" "refund" "spend" "reward" "gift" "admin" "transfer"
Example: source=spend
environment
string
Enum: "PROD" "SANDBOX" "TEST"
Example: environment=PROD
status
string
Enum: "confirmed" "pending" "failed"
Example: status=confirmed
dateFrom
string <date-time>
Example: dateFrom=2026-05-01T00:00:00Z

Inclusive lower bound on created_at.

dateTo
string <date-time>
Example: dateTo=2026-05-05T23:59:59Z

Exclusive upper bound on created_at.

limit
integer [ 1 .. 200 ]
Default: 50
Example: limit=50
offset
integer >= 0
Default: 0
Example: offset=0
groupWheel
boolean
Default: false
Example: groupWheel=true

When true, a round's wheel bet-spends (reference_id prefix wheel_bet) collapse into one row per (user, round) carrying betCount + summed amount; RC-sync status rolls up conservatively (confirmed only if every bet synced). Payouts, refunds, and non-wheel rows stay one-per-row. Pagination + total count operate on grouped units.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "items": [
    ],
  • "pagination": {
    },
  • "kpis": {
    },
  • "filters": {
    }
}

Aggregated coin-economy report grouped by economy kind

Backs the admin-panel "Coin Economy Report". Aggregates the coin_history ledger over a date range into a report grouped by economy kind (via App\Services\Reports\CoinEconomyTaxonomy). Each of the 13 kinds carries a flow role (source | sink | circulation | ops), a per-source breakdown (metadata.source; wheel is split into room vs daily by reference_id prefix), and per-source top contributors. Also returns a top-contributor-users leaderboard and a daily cumulative-net trend.

Real users only — dev (users.account_type='dev') and synthetic (callerid LIKE '9990%') users are excluded at the SQL layer. Reads only coin_history; SIGN(amount) gives inflow/outflow; amounts are signed coins.

Authorizations:
AdminKey
query Parameters
dateFrom
required
string <date>
Example: dateFrom=2026-07-01

Inclusive lower bound (YYYY-MM-DD). Required.

dateTo
required
string <date>
Example: dateTo=2026-07-13

Inclusive upper bound (YYYY-MM-DD). Required. Span capped at 92 days.

environment
string
Default: "PROD"
Enum: "PROD" "SANDBOX" "TEST"
Example: environment=PROD

Responses

Response samples

Content type
application/json
{
  • "period": {
    },
  • "scope": {
    },
  • "totals": {
    },
  • "trend": [
    ],
  • "kinds": [
    ],
  • "topUsers": [
    ]
}

Admin-driven test-coin grant (X-Admin-Key authed)

Grants coins to an arbitrary callerId and tags the row so it's visually distinguishable from real IAP / sandbox-IAP / real rewards. X-Admin-Key authed and is the only path the admin-panel uses. (Replaces the old user-self-grant /grantTestCoins, removed pre-launch — it was JWT-only with no admin gate.)

Goes through WalletService::grantCoins, so the same sync-first pipeline that handles every other coin movement applies (MySQL commit then RC mirror via RevenueCatClient::adjust).

Hardcoded by the server (NEVER trusted from client):

  • metadata.source = "admin"
  • metadata.subSource = "test_grant"
  • metadata.environment = "TEST"
  • reference_id = "admin_test_grant:<adminUserId>:<idempotencyKey>"
Authorizations:
AdminKey
Request Body schema: application/json
required
callerId
required
string

Target user callerid (digits only, see CLAUDE.md "callerid storage form").

amount
required
integer >= 1
note
string <= 500 characters

Free-text reason; lands in metadata, capped server-side.

idempotencyKey
required
string

UUID supplied by the admin-panel; namespaced into the reference_id.

adminUserId
required
string

Audit field — admin-panel session username. Required.

Responses

Request samples

Content type
application/json
{
  • "callerId": "string",
  • "amount": 1,
  • "note": "string",
  • "idempotencyKey": "string",
  • "adminUserId": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "grant": {
    }
}

List peer-to-peer coin transfers

Filterable, keyset-paginated view of coin_transfers. Reads only that table — never coin_history — so both window indexes cover it.

An unknown senderCallerId/recipientCallerId matches nothing rather than being ignored, so a typo cannot silently widen the query to every transfer.

Authorizations:
None
query Parameters
senderCallerId
string

Exact callerid of the sender.

recipientCallerId
string

Exact callerid of the recipient.

minAmount
integer

Minimum gross amount.

fromMs
integer <int64>
toMs
integer <int64>
limit
integer [ 1 .. 100 ]
Default: 50
cursor
integer

transferId of the last row from the previous page.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "transfers": [
    ],
  • "hasMore": true,
  • "cursor": 1042
}

Transfer fan-out detection report

The detection layer for peer-to-peer transfers. Because the product ships open addressing with no sender gate and no reversal, this report is the operational answer to alt-farm funnels and wash trading — it is not a nice-to-have.

  • funnels — accounts fed by two or more distinct senders in the window, ranked by sender count. This is the alt-farm signature.
  • reciprocalPairs — pairs where coins demonstrably went out and came back, ranked by washedCoins = LEAST(aToB, bToA) so genuine round-trips outrank one-directional generosity.
  • topSenders / topRecipients are separate lists on purpose: the two ledger sources are distinct so a heavy funnel account cannot net its sends against its receipts and disappear from the report.

Window defaults to the last 7 days and is capped at 92 days, matching the economy report.

Authorizations:
None
query Parameters
fromMs
integer <int64>

Defaults to toMs minus 7 days.

toMs
integer <int64>

Defaults to now.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "period": {
    },
  • "totals": {
    },
  • "topSenders": [
    ],
  • "topRecipients": [
    ],
  • "funnels": [
    ],
  • "reciprocalPairs": [
    ]
}

admin-revenuecat-sync

Admin monitor for the RevenueCat coin-sync pipeline (worker liveness, queue depth, recent failures)

Snapshot of the RevenueCat coin-sync pipeline

Read-only inspector for the RevenueCat coin-sync pipeline. Surfaces:

  • Worker liveness — derived from a Redis heartbeat key (revenuecat:worker:heartbeat). Status is active when the heartbeat is < 10s old, stale when 10–30s old, and down past 30s or missing.
  • Redis queue depth — main queue (revenuecat:coin_sync_queue, LIST) and retry queue (revenuecat:coin_sync_retry, ZSET keyed by retry-at epoch ms).
  • Database sync state — counts of negative-amount coin_history rows still pending, currently retrying (attempts > 0), and rows successfully synced today. Rows marked terminally undeliverable (revenuecat_reason IS NOT NULL) are excluded from these counts and surfaced separately under abandonedByReason.
  • Abandoned by reason — per-reason count of rows that won't be retried. The worker only auto-sets INVALID_DATA (local pre-RC validation failed) and MAX_ATTEMPTS (exceeded retry cap). GHOST_USER and RC_FATAL_OTHER are reserved for future manual admin classification — RC 4xx responses are deliberately left visible at revenuecat_synced=0 (no reason) because the same error can mean drift, which is exactly what the monitor exists to surface.
  • Recent failed/pending items — up to ?limit= rows from coin_history where amount < 0 AND revenuecat_synced = 0, ordered with still-in-play rows first and abandoned rows last. revenuecat_last_error is truncated to 500 characters server-side. revenuecatReason is non-null for abandoned rows.
  • Overall healthok / warn / crit rollup driven by worker status + queue depth + retry/failure counts.

All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header). Used by the admin-panel monitoring page.

Authorizations:
AdminKey
query Parameters
limit
integer [ 1 .. 200 ]
Default: 50
Example: limit=50

Page size for the items array (clamped 1..200, default 50).

offset
integer >= 0
Default: 0
Example: offset=0

Row offset for server-side pagination. Use with limit and the pagination.total returned in the response.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Re-enqueue abandoned coin_history rows for RC sync

Clear revenuecat_reason, revenuecat_last_error, and revenuecat_sync_attempts on each matching row, then push the row back onto the worker's main queue. Use after fixing the underlying cause of a class of failures — for example after rotating the RevenueCat API key and restarting the long-running daemons that cached the old value.

Caller must supply EITHER an explicit ids list OR a reason filter (optionally narrowed by since and limit). The two forms are mutually exclusive at the application level — if both are present, ids wins.

The endpoint is idempotent at the row level: rows that were cleared but no longer eligible (e.g. concurrent worker resolution) are counted under skipped and not requeued. Successfully requeued ids are returned so the panel can correlate.

Authorizations:
AdminKey
Request Body schema: application/json
required
ids
Array of integers[ items >= 1 ]

Explicit coin_history.id values to clear and re-enqueue.

reason
string
Enum: "GHOST_USER" "INVALID_DATA" "RC_FATAL_OTHER" "RC_DRIFT" "MAX_ATTEMPTS"

Restrict to rows currently marked with this reason.

since
string

Only rows with created_at >= since (ISO datetime). Optional.

limit
integer [ 1 .. 500 ]
Default: 100

Cap on rows to re-enqueue per call. Default 100, max 500.

Responses

Request samples

Content type
application/json
Example
{
  • "ids": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Reconcile a drift / fatal coin_history row against RevenueCat

For an RC_DRIFT or RC_FATAL_OTHER (or any non-NULL revenuecat_reason) row, read both sides' current balances, optionally call RC's adjust API to bring RC into lockstep with MySQL (MySQL is authoritative), write a zero-amount type='reconcile' audit row that preserves the original transaction's metadata.source, and mark the original row revenuecat_reason='RECONCILED'.

Two-phase by contract — pass dryRun:true first to preview what would change, then dryRun:false to apply.

The audit row is finance/audit's evidence trail. Its metadata carries: the original row id and reason, the operator id and their typed reason, the RC and MySQL balances at reconciliation time, the delta applied to RC, and the RC HTTP status. Filtering the coin-history panel by source returns the original transaction AND its reconciliation side-by-side — the full story is intact.

RC's virtual_currencies/transactions adjustments do not appear in RC's monetary finance reports (those only cover real-money IAP). The reconcile is invisible to finance dashboards by design.

Authorizations:
AdminKey
path Parameters
coinHistoryId
required
integer >= 1

The coin_history.id of the row to reconcile.

Request Body schema: application/json
required
operatorId
required
string

Identifier of the admin performing the reconcile. Stored on the audit row's metadata.operatorId for traceability.

operatorReason
required
string

Free-text justification (Turkish or English). Stored on the audit row's metadata.operatorReason. This is what an auditor reads to understand WHY the operator reconciled.

dryRun
boolean
Default: false

When true, the endpoint returns a preview of what would change (current MySQL/RC balances, RC delta that would be applied) without writing anything or calling RC's adjust endpoint. Use first; then re-call with dryRun:false to apply.

Responses

Request samples

Content type
application/json
Example
{
  • "operatorId": "admin@telpass",
  • "operatorReason": "RC was zeroed at launch; balances now match",
  • "dryRun": true
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

admin-revenuecat-catalog

Live RevenueCat product catalog (offerings/packages/products) read-through for panels

Live RevenueCat product catalog (offerings → packages → products)

Read-through view of the RevenueCat v2 catalog — offerings, their packages, and each package's products (store identifier, display name, type, indicative price). This is the source of truth for "what do we sell and for how much" after the hand-maintained coin_products MySQL mirror was dropped; tp_panel's economy page and future consumers read it here instead of a table that could only drift.

Filtering. ?types=coin,ftu,vip returns only offerings classified into one of the listed types; omit it to return every offering. An offering's types come from system_config rc.offering_type_map (a JSON { type: [lookupKey, ...] } map); when unset, an offering is classified by whether coin / ftu / vip appears in its lookup key or display name.

Caching. The full (unfiltered) catalog is cached in Redis with a TTL from system_config rc.catalog.cache_ttl_seconds (default 300s; <= 0 disables). ?refresh=1 bypasses the cache and repopulates it. meta.fromCache / meta.builtAtMs report cache provenance.

All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header). The RC v2 secret never leaves the server; the mobile client gets localized offerings straight from the RevenueCat SDK and does not call this.

Authorizations:
AdminKey
query Parameters
types
string
Example: types=coin,ftu,vip

Comma-separated offering-type filter. Omit or leave empty to return all offerings.

refresh
string
Default: "0"
Enum: "0" "1" "true" "false" "yes"

Set to 1/true/yes to bypass the Redis cache and refetch from RevenueCat.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "offerings": [
    ],
  • "meta": {
    }
}

admin-otp

Admin OTP history — paginated otp_requests view for support / fraud triage

Paginated `otp_requests` history (admin panel)

Read-only forensic view of OTP issuance — phone, purpose, IP, attempt count, status, timestamps. Backs the admin-panel "OTP History" page. Useful for support ("OTP didn't arrive / didn't work") and fraud triage (high attempt_count, repeated phones from the same IP).

All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).

Status is derived in SQL — the table only carries is_used + expires_at:

  • usedis_used = 1
  • expiredis_used = 0 AND expires_at < NOW()
  • pendingis_used = 0 AND expires_at >= NOW()

Security: never returns otp_code_hash. Each row carries a boolean hasCode (presence) so admins can confirm a record was created without seeing the hash.

Authorizations:
AdminKey
query Parameters
phone
string
Example: phone=90555

Substring match against phone (LIKE %…%).

purpose
string
Enum: "login" "register" "verify_phone"
Example: purpose=login
status
string
Enum: "used" "pending" "expired"
Example: status=used
ipAddress
string
Example: ipAddress=203.0.113.42

Exact match on ip_address.

minAttemptCount
integer >= 0
Example: minAttemptCount=3

Floor on attempt_count — useful for spotting brute-force attempts.

dateFrom
string <date-time>
Example: dateFrom=2026-05-01T00:00:00Z

Inclusive lower bound on created_at.

dateTo
string <date-time>
Example: dateTo=2026-05-05T23:59:59Z

Exclusive upper bound on created_at.

limit
integer [ 1 .. 200 ]
Default: 50
Example: limit=50
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "items": [
    ],
  • "pagination": {
    },
  • "kpis": {
    },
  • "filters": {
    }
}

admin-wheel

Admin wheel operations (currently — daily-spin reset)

Reset all users' daily wheel spin counters (cron-invoked)

Resets every user's daily_wheel_spin allowance to the configured tier amount. Intended to be called by a daily cron job. JWT-exempt because it runs unattended; locked down by network ACL.

No request body. Returns a summary of how many users were touched.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-vip

Admin VIP status lookup by callerId (reads live from IVR)

Admin lookup of a user's VIP status by phone (callerId)

Returns the IVR-derived VIP status for the given callerId. Same active-VIP rule as /vip/me: uuid set AND subscriptionsEnd in the future.

Authorizations:
AdminKey
path Parameters
callerId
required
string

Phone number in callerid form (digits only; leading 00 stripped).

Responses

Response samples

Content type
application/json
{
  • "callerId": "string",
  • "data": {
    }
}

admin-voice-rooms

Voice room moderation — search active rooms by name, admin force-close, and eject a single member with a time-limited re-entry block (X-Admin-Key).

List active voice rooms (moderation)

Support / content moderation: search within room_name (q), filter by type (group / vip). The response returns roomName, the host hostCallerId and occupancy counters. Authenticated with X-Admin-Key (ADMIN_API_KEY).

Authorizations:
AdminKey
query Parameters
type
string
Enum: "group" "vip"
Example: type=group
q
string
Example: q=sohbet

Substring match on the room name (LIKE %q%).

page
integer >= 1
Default: 1
Example: page=1
limit
integer [ 1 .. 100 ]
Default: 30
Example: limit=30

Responses

Response samples

Content type
application/json
{
  • "rooms": [
    ],
  • "total": 0,
  • "page": 0,
  • "limit": 0
}

List the IVR routing table (sip_rooms)

sip_rooms is the routing table owned by the IVR/FreeSWITCH side, so the operator surface returns every record — including disabled (status=0) rows. Authenticated with X-Admin-Key (ADMIN_API_KEY).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "rooms": [
    ]
}

Merge app and IVR rooms on zego_room_id

Performs a full outer join between sip_rooms and voice_rooms on zego_room_id — emulating with LEFT JOIN + UNION a join MySQL does not provide. Rooms that exist only on the IVR side or only in the app stay in the list (presentInApp / presentInIvr). Live occupancy counters are computed from voice_room_members by is_sip; sip_rooms.current_sip_users is never used, because it is maintained externally.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "rooms": [
    ]
}

Update sip_rooms operator settings (maxUser / priority / status)

Partially updates the only three operator-settable fields; fields not sent are left untouched. If maxUser exceeds the seat count of the matching active room the request is not rejected — the response carries a SIP_CAP_EXCEEDS_SEATS warning instead.

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
maxUser
integer >= 0
priority
integer >= 0
status
integer
Enum: 0 1

Responses

Request samples

Content type
application/json
{
  • "maxUser": 0,
  • "priority": 0,
  • "status": 0
}

Response samples

Content type
application/json
{
  • "data": {
    },
  • "warnings": [
    ]
}

Lift a re-entry block early

Soft-deletes the block and clears the cache (voiceroom:kicks:active:v1). Lifting an already-lifted or expired kick is idempotent — it still returns 204. Lifting does not put the user back in the room: they are already out, this only removes the re-entry cooldown.

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
optional
revokedBy
string or null

Optional operator label (default admin).

Responses

Request samples

Content type
application/json
{
  • "revokedBy": "operator-42"
}

Response samples

Content type
application/json
{
  • "error": {
    }
}

Eject a member from a room (with a time-limited re-entry block)

Vacates the target's seat (if any), closes their membership, fail-open disconnects them from the LiveKit media room, and writes a time-limited, room-scoped re-entry block (voice_room_kicks). With alsoRestrict:true an app-wide no_voice_room restriction can optionally be applied as well — that second step runs after the kick and does not fail the request if it errors; instead the response returns restrictionId: null and warnings: [{code: "RESTRICTION_NOT_APPLIED"}].

The room host cannot be ejected (409 CANNOT_KICK_HOST) — in VIP rooms that would silently trigger the host-grace auto-close; the tool the operator wants there is force-close (DELETE /{roomID}).

Authorizations:
AdminKey
path Parameters
roomID
required
string
Request Body schema: application/json
required
userKey
required
string

Callerid of the user to eject, or p_<phone> for a SIP caller.

reasonCode
required
string
Enum: "harassment" "hate_speech" "nsfw_sexual" "underage" "spam_scam" "fraud_chargeback" "impersonation" "violence_threats" "self_harm" "ban_evasion" "other"

Structured moderation reason code (fixed catalog; GET /admin/moderation/reasons).

note
string or null

Free-text moderator note (internal; never leaks to the user).

cooldownSeconds
integer or null [ 60 .. 86400 ]

Duration of the re-entry block. When omitted, system_config.voice_room_kick_cooldown_seconds (default 600) is used. An out-of-range system_config value is silently clamped; an out-of-range value sent explicitly here is rejected with 400 INVALID_PARAM.

issuedBy
string or null

Optional operator label (default admin).

alsoRestrict
boolean
Default: false

When true, an app-wide no_voice_room restriction is applied to the user in addition to the kick (RestrictionService).

restrictExpiresAtMs
integer or null <int64>

Used together with alsoRestrict. null makes the restriction permanent. Ignored while alsoRestrict=false.

Responses

Request samples

Content type
application/json
{
  • "userKey": "905551234567",
  • "reasonCode": "harassment",
  • "note": "mikrofonda küfür",
  • "cooldownSeconds": 600,
  • "issuedBy": "operator-42",
  • "alsoRestrict": false,
  • "restrictExpiresAtMs": 1730000000000
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "kickId": 12,
  • "roomId": "string",
  • "userKey": "string",
  • "cooldownExpiresAtMs": 1730000600000,
  • "seatFreed": true,
  • "rtcDisconnected": true,
  • "restrictionId": 0,
  • "warnings": [
    ]
}

List a room's active and historical re-entry blocks

Feeds the Active ejections section of the panel drawer. By default it returns only active (unexpired, unlifted) blocks; includeExpired=1 includes history as well.

Authorizations:
AdminKey
path Parameters
roomID
required
string
query Parameters
includeExpired
boolean
Default: false
limit
integer [ 1 .. 200 ]
Default: 50
offset
integer >= 0
Default: 0

Responses

Response samples

Content type
application/json
{
  • "kicks": [
    ]
}

Force-close a room (no host check)

Deletes the active room and its member / seat data; room_closed is broadcast to WebSocket clients. Useful for inappropriate room names; the host can also close the room via the normal DELETE /api/voice-rooms/{roomID}.

Authorizations:
AdminKey
path Parameters
roomID
required
string

Responses

Response samples

Content type
application/json
{
  • "error": {
    }
}

admin-presence

Monitoring panel — app-wide online user count (real / synthetic), built on the Redis presence keys (X-Admin-Key).

App-wide online user count (real / synthetic / dev)

Instantaneous online user count for the monitoring panel (tp_panel), split three ways: dev (staff accounts — account_type='dev' or callerids listed in the presence_dev_callerids config), synthetic (9990-prefixed callerids) and real (the remaining genuine players). real = total - synthetic - dev. It is computed by scanning the Redis presence:u:* keys (SCAN) and cached for presence_count_cache_seconds (default 15s), so many panel queries share a single scan. While the cache is cold, concurrent scans are prevented with a SET NX lock (the winning scan releases the lock when it finishes). When a fresh scan is not possible (the lock is held by another query, or Redis errored) the last successful count — from the persistent presence:count:last snapshot — is returned with stale: true; that way the count only reads zero on a genuine cold start (when no prior scan exists). The hot write path (PresenceTouchMiddleware) is unchanged. When presence_enabled = false all counts return zero. Authenticated with X-Admin-Key (ADMIN_API_KEY).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "total": 4213,
  • "real": 3105,
  • "synthetic": 1103,
  • "dev": 5,
  • "asOfMs": 1719100000000,
  • "stale": false,
  • "calls": {
    }
}

Online user list (callerId + last seen + kind)

Raw online user list for the monitoring panel's (tp_panel) live user monitor. It uses the same Redis presence:u:* key scan (SCAN + MGET) as GET /admin/presence/online-count, but instead of a total it returns each user's callerId, lastSeenMs (epoch ms) and kind (dev / synthetic / real, the same classification as online-count). It is not cached — the panel polls it about every 10s as a fresh, instantaneous scan. It does not touch the DB; enrichments such as username, location and history are read DB-direct on the tp_panel side. Authenticated with X-Admin-Key (ADMIN_API_KEY).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "asOfMs": 1719100000000,
  • "total": 2,
  • "stale": false,
  • "users": [
    ]
}

admin-bans

Server-side bans — create, list and lift bans scoped to user / device / ip (single IP or CIDR) (X-Admin-Key). See docs/systems/bans.md.

List bans (scope-filtered, paginated)

Lists server-side bans. Filterable by scope (user / device / ip); paginated with limit (default 50, max 200) and offset. Active, expired and lifted bans are all returned; status is read from the expiresAtMs / revokedAtMs fields.

Each row carries two fields beyond the stored record: subject (the resolved identity, user scope only, otherwise null) and correlation (a cached correlation summary). The body also returns total for the unpaginated count.

q searches both the raw subject_value substring and resolves username / display name to a callerid, so a user-scoped ban is findable by name too. createdFromMs / createdToMs bound the created_at range and are epoch ms on the wire.

userKey is the one filter that changes the shape of the answer: it narrows the list to the subjects belonging to ONE user — their callerid, the device ids they have registered, and any ip-scope ban whose range covers an IP they have signed in from (so a /24 block ban still surfaces on their page). It is not a substring match, and it is not the same as q. Without it the endpoint answers with the whole ban table. Authenticated with X-Admin-Key (ADMIN_API_KEY). See docs/systems/bans.md.

Authorizations:
AdminKey
query Parameters
scope
string
Enum: "user" "device" "ip"

Scope filter.

userKey
string

Restrict to one user's own subjects: their callerid, their registered device ids, and any ip-scope ban whose range covers an IP they signed in from. Unknown callerids still match their own user-scope bans.

q
string

subject_value substring search (callerId / device / IP) plus name resolution.

status
string
Default: "all"
Enum: "active" "expired" "lifted" "all"

Status filter. Invalid values yield INVALID_STATUS.

reasonCode
string
Enum: "harassment" "hate_speech" "nsfw_sexual" "underage" "spam_scam" "fraud_chargeback" "impersonation" "violence_threats" "self_harm" "ban_evasion" "other"

Structured reason-code filter. Invalid values yield INVALID_REASON_CODE.

issuedBy
string

Operator-label filter.

createdFromMs
integer <int64>

created_at lower bound (epoch ms).

createdToMs
integer <int64>

created_at upper bound (epoch ms).

limit
integer [ 1 .. 200 ]
Default: 50
offset
integer >= 0
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": [
    ],
  • "total": 137
}

Create a ban (user / device / ip or CIDR)

Creates a new ban. For ip scope, value may be a single IP address or a CIDR block (e.g. 203.0.113.0/24); invalid input returns INVALID_IP. Without expiresAtMs the ban is permanent. issuedBy is an optional operator label (default admin; /admin uses a shared key, so there is no operator identity). Authenticated with X-Admin-Key.

Authorizations:
AdminKey
Request Body schema: application/json
required
scope
required
string
Enum: "user" "device" "ip"
value
required
string

user → callerid, device → device identifier, ip → a single IP or a CIDR block (e.g. 203.0.113.7 or 203.0.113.0/24).

reasonCode
string
Enum: "harassment" "hate_speech" "nsfw_sexual" "underage" "spam_scam" "fraud_chargeback" "impersonation" "violence_threats" "self_harm" "ban_evasion" "other"

Structured reason code (fixed catalog). Defaults to other when omitted.

reason
string

Free-text moderator note (NOT the structured reason).

expiresAtMs
integer or null <int64>

The ban is permanent when omitted.

issuedBy
string

Optional operator label (default admin).

Responses

Request samples

Content type
application/json
{
  • "scope": "ip",
  • "value": "203.0.113.0/24",
  • "reasonCode": "harassment",
  • "reason": "çirkin dil",
  • "expiresAtMs": 1730000000000,
  • "issuedBy": "operator-42"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Lift a ban (soft-delete)

Lifts a ban (stamps revoked_at; the record is retained for audit). The operation is idempotent — an already-lifted ban returns lifted: false. revokedBy is an optional operator label (default admin). Authenticated with X-Admin-Key.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>
Request Body schema: application/json
optional
revokedBy
string

Responses

Request samples

Content type
application/json
{
  • "revokedBy": "operator-42"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Correlation tree for a ban subject (linked accounts / devices / IPs)

Returns the accounts, devices and IPs linked behind this ban's subject (scope + subject_value), each stamped with its own ban status and graded with its own confidence level. The source is the user_identifier_sightings record written at sign-in — not user_sessions / user_devices.

Three things are easy to misread, and the body carries them explicitly: (1) accounts never includes the subject ITSELF, so an empty array means "no other account is linked"; (2) the unknowable level is NOT the same as none — an auto- device identifier carries the userId inside the hash, so by definition it maps to a single account; (3) the shared-IP judgement is INFERRED from account spread, not measured (no ASN/geo data is stored), which is why every IP judgement carries inferredFrom.

coverage exists so the caller can tell "we looked and found nothing" apart from "we cannot know"; while coverage.backfilled is false, an empty tree is not an exoneration. The read is cached for 300 s. Authenticated with X-Admin-Key. See docs/systems/bans.md.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

admin-moderation

Shared moderation reference — the fixed reason-code catalog used by bans and restrictions (X-Admin-Key). See docs/systems/bans.md.

Moderation reason-code catalog

Returns the fixed reason-code catalog used by ban and restriction actions. tp_panel renders this list as a dropdown; the labels are localized by code on the tp_panel i18n side. Adding a code requires a shuffly deploy (it is a fixed enum). Authenticated with X-Admin-Key (ADMIN_API_KEY). See docs/systems/bans.md.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": [
    ]
}

A user's known device and IP identifiers

Returns a user's known device identifiers (user_devices) and IP addresses (user_sessions, grouped with a session count), so the moderation panel can offer one-click device/IP bans without the moderator needing to know the exact value. Authenticated with X-Admin-Key. See docs/systems/bans.md.

Authorizations:
AdminKey
query Parameters
userKey
required
string

The user's callerid.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

How many accounts a prospective ban would hit (never cached)

Returns how many accounts a ban would affect BEFORE it is applied, split into those already banned and those still clean; it also samples at most 10 of the clean accounts. The panel shows this at the confirmation step.

This endpoint is deliberately not cached. It gates a destructive action, so a stale number means the moderator bans more people than they were shown. The correlation tree itself is cached for 300 s; this is not.

scope may be user / device / ip; for ip, value accepts a single IP or a CIDR block. Authenticated with X-Admin-Key. See docs/systems/bans.md.

Authorizations:
AdminKey
query Parameters
scope
required
string
Enum: "user" "device" "ip"

Subject scope. Any other value returns INVALID_SCOPE.

value
required
string

Subject value: a callerid, a device identifier or an IP/CIDR.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

admin-restrictions

Capability-scoped restrictions — timed or permanent non-ban punishments for voice rooms / speaking on stage / 1:1 calls / DMs; create, list and lift (X-Admin-Key). See docs/systems/restrictions.md.

List restrictions (user/capability-filtered, paginated)

Lists restrictions. Filterable by userKey (callerid) and capability; paginated with limit (default 50, max 200) and offset. Active, expired and lifted records are all returned; status is read from the expiresAtMs / revokedAtMs fields.

It carries the same two enrichments as bans: subject (the resolved identity) and correlation (a cached correlation summary); the body also returns the unpaginated total. Unlike the ban list, there are no date-range parameters here. Authenticated with X-Admin-Key (ADMIN_API_KEY). See docs/systems/restrictions.md, docs/systems/bans.md.

Authorizations:
AdminKey
query Parameters
userKey
string

User (callerid) filter.

capability
string
Enum: "no_voice_room" "no_room_speak" "no_call" "no_dm"

Capability filter.

q
string

user_key substring search (callerId).

status
string
Default: "all"
Enum: "active" "expired" "lifted" "all"

Status filter. Invalid values yield INVALID_STATUS.

reasonCode
string
Enum: "harassment" "hate_speech" "nsfw_sexual" "underage" "spam_scam" "fraud_chargeback" "impersonation" "violence_threats" "self_harm" "ban_evasion" "other"

Structured reason-code filter. Invalid values yield INVALID_REASON_CODE.

issuedBy
string

Operator-label filter.

limit
integer [ 1 .. 200 ]
Default: 50
offset
integer >= 0
Default: 0

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": [
    ],
  • "total": 42
}

Create one restriction, or a whole capability set

Creates one or more restrictions for a user.

The body carries either capability (one) or capabilities (a set applied in one shot, all sharing the same reasonCode, note and expiresAtMs). The response mirrors the request: data is a single object for capability and an array for capabilities, so single-capability callers keep the body they were written against.

Every member of a capabilities set is validated BEFORE the first insert, so a rejected set leaves nothing behind — no half-applied punishment. Capabilities are a fixed enum; invalid input (including a non-array or empty capabilities) returns INVALID_CAPABILITY. reasonCode is validated against the fixed reason catalog (INVALID_REASON_CODE); it defaults to other when omitted. Without expiresAtMs the restriction is permanent. note is an internal moderator note and never leaks to the user. Enforcement is toggled with the restrictions_enforcement_enabled flag (on by default). Authenticated with X-Admin-Key.

Authorizations:
AdminKey
Request Body schema: application/json
required
One of
userKey
required
string

Callerid of the user to restrict.

capability
required
string
Enum: "no_voice_room" "no_room_speak" "no_call" "no_dm"
capabilities
Array of strings non-empty unique
Items Enum: "no_voice_room" "no_room_speak" "no_call" "no_dm"

A capability set applied in one call, sharing this request's reasonCode, note and expiry. Validated in full before the first insert. Duplicates are collapsed.

reasonCode
string
Enum: "harassment" "hate_speech" "nsfw_sexual" "underage" "spam_scam" "fraud_chargeback" "impersonation" "violence_threats" "self_harm" "ban_evasion" "other"

Structured reason code (fixed catalog). Defaults to other when omitted.

note
string

Free-text moderator note (internal).

expiresAtMs
integer or null <int64>

The restriction is permanent when omitted.

issuedBy
string

Optional operator label (default admin).

Responses

Request samples

Content type
application/json
{
  • "userKey": "905551112233",
  • "capability": "no_call",
  • "capabilities": [
    ],
  • "reasonCode": "harassment",
  • "note": "çirkin dil",
  • "expiresAtMs": 1730000000000,
  • "issuedBy": "operator-42"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Lift a restriction (soft-delete)

Lifts a restriction (stamps revoked_at; the record is retained for audit). The operation is idempotent — an already-lifted record returns lifted: false. revokedBy is an optional operator label (default admin). Authenticated with X-Admin-Key.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>
Request Body schema: application/json
optional
revokedBy
string

Responses

Request samples

Content type
application/json
{
  • "revokedBy": "operator-42"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

admin-profile-images

Manual profile-photo moderation — a moderator overriding an upload's AI verdict by approving/rejecting it (X-Admin-Key), with audit fields and a reason code. See docs/systems/profile-images.md.

Manually approve/reject a profile photo (moderator override)

A moderator's manual override of the AI verdict on a profile photo upload. With decision: approve the status becomes approved, with reject it becomes rejected, and reviewed_at plus the audit fields (reviewed_by, review_reason_code, review_reason) are stamped.

Approving does not change the displayed image (unlike CDN approval it does not auto-select the slot); rejecting recomputes the denormalized columns — if the rejected photo was the one on display, the profile falls back to the avatar. reasonCode is required when rejecting (REASON_REQUIRED). Authenticated with X-Admin-Key (ADMIN_API_KEY). See docs/systems/profile-images.md.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

The user_profile_images row id.

Request Body schema: application/json
required
decision
required
string
Enum: "approve" "reject"

The moderator's decision.

reviewedBy
string <= 191 characters

Moderator identity (the admin the panel authenticated). Defaults to admin when omitted.

reasonCode
string <= 40 characters

Fixed reason code. Required when rejecting. Rejection codes: nudity, sexual, violence, offensive, not_a_person, impersonation, minor_safety, other. Approval (optional): false_positive, other. The labels live in the panel's i18n.

reason
string <= 255 characters

Free-text note (optional).

Responses

Request samples

Content type
application/json
{
  • "decision": "approve",
  • "reviewedBy": "ahmet.k",
  • "reasonCode": "offensive",
  • "reason": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

admin-leaderboards

Operator surface for the weekly leaderboard — inspect a week, force a recompute, or settle and mint rewards. Settling is idempotent and safe to re-run. JWT-exempt, guarded by X-Admin-Key. See docs/systems/leaderboard.md.

Operator view of one leaderboard week

Week state, pool arithmetic, per-board rank counts, why users were excluded, and reward totals by status.

ineligible is how "why am I not on the board" is answered. Note that opted_out appears there but is not an exclusion — those users are ranked and paid, only hidden from public listings.

X-Admin-Key guarded and JWT-exempt.

Authorizations:
None
path Parameters
week
required
string
Example: 2026-W30

ISO week key in UTC, or current.

Responses

Response samples

Content type
application/json
{
  • "weekId": "string",
  • "status": "open",
  • "startsAtMs": 0,
  • "endsAtMs": 0,
  • "ruleVersion": 0,
  • "poolTotal": 0,
  • "carriedIn": 0,
  • "carriedOut": 0,
  • "boards": [
    ],
  • "ineligible": [
    ],
  • "rewards": [
    ]
}

Force a full recompute of a week

Scores are recomputed from activity_events rather than accumulated, so this is idempotent by construction — a rerun over an unchanged week produces byte-identical output. This is also how a scoring bug is repaired: redeploy, then rerun.

Forces through the scored / paid guard. Ranks may move under rewards already minted, so prefer this before a week is settled.

Authorizations:
None
path Parameters
week
required
string
Example: 2026-W30

Responses

Response samples

Content type
application/json
{
  • "weekId": "2026-W30",
  • "users": 0,
  • "boards": {
    }
}

Freeze, score, rank and mint rewards for a week

Drives open → frozen → scored → paid. Idempotent — safe to re-run after any failure; it resumes rather than double-paying, because reward rows are keyed on (week, board, user).

Minting moves no coins: it writes claimable rows that users claim via POST /v1/leaderboards/rewards/{id}/claim.

Payout is deliberately asynchronous. Freeze happens on time; results publish when the job finishes, so a slow run degrades to "results are late" rather than "half a week is paid".

Authorizations:
None
path Parameters
week
required
string
Example: 2026-W30

Responses

Response samples

Content type
application/json
{
  • "weekId": "2026-W30",
  • "status": "paid",
  • "pool": 0,
  • "awards": 0,
  • "carriedOut": 0
}

admin-quest-sets

Operator view of a user's daily quest set — inspect slots with live progress plus ladder/weekly state, force a redraw, or override a single slot. User-scoped rather than quest-scoped: a set belongs to a user. All routes are JWT-exempt and guarded by X-Admin-Key. See docs/systems/quests.md.

Inspect a user's daily quest set

The operator view of one user's assigned set for a period: every slot with its quest, live progress and completion, plus ladder state, weekly state, tier and remaining reroll budget.

Reading this from the database by hand means joining four tables and knowing the period-key convention, so support gets it as an endpoint.

JWT-exempt; guarded by X-Admin-Key.

Authorizations:
BearerAuth
path Parameters
userId
required
integer >= 1

Numeric users.id (NOT a callerid).

query Parameters
periodKey
string
Example: periodKey=2026-07-21

Daily period key YYYY-MM-DD. Defaults to today in the quests timezone (quests_timezone, default Europe/Istanbul).

Responses

Response samples

Content type
application/json
{
  • "userId": 1234,
  • "callerId": "905344546002",
  • "periodKey": "2026-07-21",
  • "tier": "free",
  • "setSize": 5,
  • "slots": [
    ],
  • "rerolls": {
    },
  • "ladder": {
    },
  • "weekly": {
    }
}

Drop and redraw a user's set for a period

Deletes the period's assignments and draws a fresh set.

The persisted tier is preserved, so a redraw cannot silently promote or demote the user's set size or rung amounts.

Progress rows are deliberately left in place: the ladder counts only assigned quests, so orphaned progress is inert, and deleting it would destroy evidence while support is still looking at the case.

JWT-exempt; guarded by X-Admin-Key.

Authorizations:
BearerAuth
path Parameters
userId
required
integer >= 1
query Parameters
periodKey
string
Example: periodKey=2026-07-21

Defaults to today in the quests timezone.

Responses

Response samples

Content type
application/json
{
  • "userId": 1234,
  • "callerId": "905344546002",
  • "periodKey": "2026-07-21",
  • "tier": "free",
  • "setSize": 5,
  • "slots": [
    ],
  • "rerolls": {
    },
  • "ladder": {
    },
  • "weekly": {
    },
  • "removed": 5
}

Replace one slot of a user's set with a specific quest

Points a slot at a chosen quest. The slot's category follows the incoming quest so the row stays self-consistent.

Unlike the user-facing reroll this does not stamp rerolled_at — an operator fixing a set must not spend the user's own reroll allowance.

Only pooled quests (category set) may be placed: a standalone quest in a set would be visible and separately claimable, so it would pay twice.

JWT-exempt; guarded by X-Admin-Key.

Authorizations:
BearerAuth
path Parameters
userId
required
integer >= 1
slot
required
integer >= 0

0-based slot index within the set.

query Parameters
periodKey
string
Example: periodKey=2026-07-21

Defaults to today in the quests timezone.

Request Body schema: application/json
required
questId
required
integer

The pooled quest to place in this slot.

Responses

Request samples

Content type
application/json
{
  • "questId": 42
}

Response samples

Content type
application/json
{
  • "userId": 1234,
  • "callerId": "905344546002",
  • "periodKey": "2026-07-21",
  • "tier": "free",
  • "setSize": 5,
  • "slots": [
    ],
  • "rerolls": {
    },
  • "ladder": {
    },
  • "weekly": {
    }
}

admin-quests

Quest definition CRUD — create, read, update, activate/deactivate, soft-delete. Also exposes a /registry endpoint that enumerates available objective types, reward types, audience predicates, and periods from the server-side registries, so tp_panel dropdowns never diverge from the backend source of truth. All routes are JWT-exempt and guarded by X-Admin-Key.

List all quest definitions (non-deleted)

Returns all non-deleted quest definitions ordered by sort_order, id. Includes inactive definitions (use is_active to distinguish).

All /admin/quests routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": [
    ]
}

Create a new quest definition

Creates a quest definition. objectiveType must be known to ObjectiveRegistry and rewardType must be registered in RewardRegistry.

Accepts either application/json or multipart/form-data. Use the multipart variant to upload a background image (image part) in the same request; in that mode rewardPayload/audienceFilters/titles/ descriptions are JSON strings.

Authorizations:
AdminKey
Request Body schema:
required
code
required
string^[a-z0-9_]{1,64}$
period
required
string
Enum: "daily" "weekly" "monthly" "one_time"
objectiveType
required
string

Must be registered in ObjectiveRegistry.

threshold
required
integer >= 1
rewardType
required
string

Must be registered in RewardRegistry.

rewardPayload
required
object
audienceFilters
Array of objects
Default: []
isActive
boolean
Default: false
isFeatured
boolean
Default: false

Feature this quest on the app homepage (homepageQuests).

isPinned
boolean
Default: false

Pin this quest to the client's dedicated section. true returns 409 ALREADY_PINNED (with details.pinnedQuestId) when a different quest already holds the pin — the generic write refuses to guess which quest to displace. Use POST /admin/quests/{id}/pin to transfer instead. false always succeeds and clears the pin.

scheduleStart
string or null <date-time>
scheduleEnd
string or null <date-time>
title
required
string <= 128 characters

Scalar fallback; when titles is present it wins and this is ignored.

object

Locale → title map (each value 1–128 chars). When present it is authoritative; the scalar title column becomes the en-preferred mirror. When absent, the scalar title back-fills {tr,en}.

description
string or null
object

Locale → body-text map; same mirror semantics as titles.

sortOrder
integer
Default: 0

Responses

Request samples

Content type
{
  • "code": "daily_spend_v1",
  • "period": "daily",
  • "objectiveType": "coins_spent",
  • "threshold": 100,
  • "rewardType": "coins",
  • "rewardPayload": {
    },
  • "audienceFilters": [
    ],
  • "isActive": false,
  • "isFeatured": false,
  • "isPinned": false,
  • "scheduleStart": "2019-08-24T14:15:22Z",
  • "scheduleEnd": "2019-08-24T14:15:22Z",
  • "title": "Günlük Harcama",
  • "titles": {
    },
  • "description": "string",
  • "descriptions": {
    },
  • "sortOrder": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Returns available objective types, reward types, audience predicates, and periods

Derives values directly from the server-side registries so the tp_panel can populate dropdowns without hardcoding. Returns all types regardless of whether a producer is wired (Phase 1: only coins_spent is live).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Effective quest background-image upload rules (panel guardrails)

Returns the effective background-image upload rules (max bytes, max dimensions, aspect window, allowed MIME types / extensions) derived from the quest_image_* system_config knobs with baked-in defaults (10 MiB, 2048×2048, aspect 1.32–1.79, png/jpeg/svg/webp).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Effective daily-set economy (ladder rungs, set sizes, weekly bonus)

The six system_config keys that govern the daily quest set, read as one unit: the system_config row where present, the QuestSetConfig container default where absent — so an unseeded install returns working numbers rather than nulls.

ladderTotals is derived, never stored: the per-tier sum of the rungs, which is the daily coin budget for pooled quests.

rerollAllowance is read-only here. The reroll budget is the quest_reroll allowance-catalog item, not a system_config key. Its values are nullable — null means no rule row (or an unwired allowance service), which is not the same fact as a budget of zero.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Update any subset of the daily-set economy, transactionally

Every field is optional; an absent field means unchanged. All present keys are validated first, then written inside one transaction — so a rejected write never leaves a half-applied economy live for users. That atomicity, plus the cross-key rungs ≤ setSize invariant, is why this exists instead of six system_config knob writes.

Rungs are not required to ascend. A flat or descending ladder is unusual but legal; the panel warns, the server allows.

Two operator hazards this endpoint does not remove. Rung amounts are read live at claim time and are not versioned, so lowering a rung mid-day lowers what an already-eligible user is about to receive. And shrinking the rung count changes which past days count as ladder-finishing for the weekly bonus, because "the final rung" is derived from the live rung count. Claimed rows are never clawed back.

Authorizations:
AdminKey
Request Body schema: application/json
required
object
object
weeklyRewardAmount
integer [ 0 .. 1000000 ]
weeklyRequiredDays
integer [ 1 .. 7 ]

Responses

Request samples

Content type
application/json
{
  • "ladderRungs": {
    },
  • "setSize": {
    },
  • "weeklyRewardAmount": 1000000,
  • "weeklyRequiredDays": 1
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Lightweight per-quest funnel counts (progressed → reached → claimed)

On-read aggregate counts for a single quest, intended for a Grafana panel or quick operator check. Heavier/multi-quest analytics are built in tp_panel directly against the app DB to keep read load off the API server.

  • progressed — distinct users with counter > 0.
  • reached — distinct users who hit the threshold (completed_at set).
  • claimed — distinct users who claimed.

Optional from/to (epoch ms) filter progressed/reached on updated_at / completed_at and claimed on claimed_at. Omitted → all-time.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Quest definition ID.

query Parameters
from
integer <int64>

Lower bound, epoch milliseconds (inclusive).

to
integer <int64>

Upper bound, epoch milliseconds (inclusive).

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Get a single quest definition by ID

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Quest definition ID.

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Partially update a quest definition (JSON)

JSON-only partial update. All fields are optional; only supplied fields are updated. objectiveType and rewardType are validated against the registries if present. To upload/replace/clear the background image, use the POST /admin/quests/{id} multipart twin below (PHP populates uploaded files only on POST).

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
period
string
Enum: "daily" "weekly" "monthly" "one_time"
objectiveType
string
threshold
integer >= 1
rewardType
string
rewardPayload
object
audienceFilters
Array of objects
isActive
boolean
isFeatured
boolean

Feature/unfeature this quest on the app homepage.

isPinned
boolean

true returns 409 ALREADY_PINNED when a different quest holds the pin; use POST /admin/quests/{id}/pin to transfer. false clears the pin and always succeeds.

scheduleStart
string or null <date-time>
scheduleEnd
string or null <date-time>
title
string <= 128 characters

Scalar patch — updates the en slot of titles and the mirror column.

object

Replaces the whole locale → title map (values 1–128 chars).

description
string or null
object

Replaces the locale → body-text map; {}/null clears both columns.

sortOrder
integer

Responses

Request samples

Content type
application/json
{
  • "period": "daily",
  • "objectiveType": "coins_spent",
  • "threshold": 1,
  • "rewardType": "string",
  • "rewardPayload": { },
  • "audienceFilters": [
    ],
  • "isActive": true,
  • "isFeatured": true,
  • "isPinned": true,
  • "scheduleStart": "2019-08-24T14:15:22Z",
  • "scheduleEnd": "2019-08-24T14:15:22Z",
  • "title": "string",
  • "titles": {
    },
  • "description": "string",
  • "descriptions": {
    },
  • "sortOrder": 0
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Partially update a quest definition (multipart — image upload/clear)

Multipart-capable twin of PATCH /admin/quests/{id} with identical update semantics, plus background-image handling: send an image file to upload/replace the image (the previous file is deleted), or imageUrl="" to clear it. Nested fields (rewardPayload, audienceFilters, titles, descriptions) are JSON strings. Also accepts application/json (behaves like PATCH).

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema:
period
string
Enum: "daily" "weekly" "monthly" "one_time"
objectiveType
string
threshold
integer >= 1
rewardType
string
rewardPayload
string
audienceFilters
string
isActive
boolean
isFeatured
boolean
isPinned
boolean

true conflicts with an existing pin holder (409 ALREADY_PINNED); use POST /admin/quests/{id}/pin to transfer. false clears the pin.

scheduleStart
string or null
scheduleEnd
string or null
title
string <= 128 characters
titles
string

JSON-encoded locale → title map (replaces the whole map).

description
string or null
descriptions
string

JSON-encoded locale → body-text map (empty string clears).

sortOrder
integer
force
boolean

Bypass SOFT image caps/aspect on the uploaded image (see CreateQuestMultipartBody).

imageUrl
string

Send an empty string to clear the existing background image (delete the file).

image
string <binary>

New quest background image (PNG/JPEG/SVG/WebP, landscape encouraged). Replaces and deletes the previous file.

Responses

Request samples

Content type
No sample

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Soft-delete a quest definition

Sets is_deleted=1, is_active=0. Existing progress and claim rows are retained for audit. No retroactive changes to user state.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Set is_active=1 on a quest definition

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Set is_active=0 on a quest definition

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Make this the pinned quest (transfers the pin)

Sets is_pinned = 1 on this quest and clears it from the previous holder, in one transaction. At most one non-deleted quest may be pinned — enforced by a unique index over a virtual pin_lock column.

This action transfers and therefore always succeeds for a live quest, so an operator swapping the pinned quest never passes through a window with nothing pinned. Setting isPinned: true through POST/PATCH /admin/quests/{id} instead returns 409 ALREADY_PINNED.

Pinning the current holder is an idempotent no-op.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Clear the pin from this quest

Sets is_pinned = 0. Afterwards GET /quests reports pinnedQuest: null until another quest is pinned.

Note that soft-deleting a pinned quest also frees the pin, because pin_lock is NULL for deleted rows.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

admin-synthetic-users

Synthetic-user admin studio. Draft → curate → commit (drafts held in memory until commit), plus browse/edit/disable/delete/purge. Every mutating action is guarded by account_type=synthetic so human rows are untouchable. The pool kill-switch + generation defaults are managed via the existing /admin/allowances/knobs/{key} system_config endpoints.

Build N draft synthetic users (no DB write)

Rolls attribute records IN MEMORY for operator review. No users row is created and no callerid is minted until commit. count is clamped to 1–200.

All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).

Authorizations:
AdminKey
Request Body schema: application/json
count
integer [ 1 .. 200 ]
object (GenerationParams)

Tuning knobs for draft generation. All fields optional; sensible defaults apply.

Responses

Request samples

Content type
application/json
{
  • "count": 10,
  • "params": {
    }
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Re-roll one or more draft records (no DB write)

Returns count (1–50) freshly rolled drafts using the supplied params. Used to refresh individual rows or a selection in the draft staging table.

Authorizations:
AdminKey
Request Body schema: application/json
count
integer [ 1 .. 50 ]
object (GenerationParams)

Tuning knobs for draft generation. All fields optional; sensible defaults apply.

Responses

Request samples

Content type
application/json
{
  • "count": 1,
  • "params": {
    }
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Persist a batch of (possibly edited) draft records

Mints 9990-prefixed callerids and inserts each draft in its own transaction; failures roll back per-row and are collected. Operator edits in the draft body land verbatim.

Authorizations:
AdminKey
Request Body schema: application/json
required
required
Array of objects (DraftRecord)
batchLabel
string
seed
string or null

Responses

Request samples

Content type
application/json
{
  • "drafts": [
    ],
  • "batchLabel": "panel",
  • "seed": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

List synthetic batches with counts

Batches grouped by batch_id with label, count, and timestamps.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Purge all synthetics in a batch

Guarded delete (AND account_type=synthetic). FK cascades clear children.

Authorizations:
AdminKey
path Parameters
batchId
required
string

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": { }
}

Paginated, filtered synthetic-user browse

Server-mode list backing the admin browse table.

Authorizations:
AdminKey
query Parameters
search
string

Substring match on display_name or callerid.

gender
string
Enum: "male" "female" "other"
batch
string

Filter to a batch_id.

enabled
string
Enum: "1" "0" "true" "false"

Enabled (not is_deleted) filter.

ageMin
integer
ageMax
integer
sortKey
string
Default: "id"
Enum: "id" "displayName" "heartCount" "createdAt" "gender"
sortDir
string
Default: "desc"
Enum: "asc" "desc"
page
integer >= 1
Default: 1
pageSize
integer [ 1 .. 100 ]
Default: 25

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Purge ALL synthetic users

Destructive — removes every account_type=synthetic row. The admin panel gates this behind a typed confirmation. Human rows are never affected.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": { }
}

Full editable detail for one synthetic user

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": {
    }
}

Edit a synthetic user (guarded)

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
displayName
string
bio
string or null
gender
string
Enum: "male" "female" "other"
birthDate
string <date>
rating
number <float>
heartCount
integer
totalSwipes
integer
secondLanguage
string
avatarCosmeticItemId
integer
interestIds
Array of integers

Responses

Request samples

Content type
application/json
{
  • "displayName": "string",
  • "bio": "string",
  • "gender": "male",
  • "birthDate": "2019-08-24",
  • "rating": 0.1,
  • "heartCount": 0,
  • "totalSwipes": 0,
  • "secondLanguage": "string",
  • "avatarCosmeticItemId": 0,
  • "interestIds": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": { }
}

Delete one synthetic user (guarded)

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": { }
}

Disable a synthetic user (is_deleted=1; pool-excluded)

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": { }
}

Enable a synthetic user (is_deleted=0; pool-eligible)

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "data": { }
}

admin-system-config

Admin runtime knobs (system_config) read/write

List all system_config knobs

Returns the full set of runtime-tunable knobs from system_config (e.g. otp_max_hourly_requests, daily_reset_hour_utc, wheel_daily_spin_limit_default/_vip). All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Read a single knob

Authorizations:
AdminKey
path Parameters
key
required
string

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Update a single knob

Authorizations:
AdminKey
path Parameters
key
required
string
Request Body schema: application/json
required
required
string or integer or boolean or number

Type-coerced server-side per the knob's declared type.

Responses

Request samples

Content type
application/json
{
  • "value": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

Alias of PUT (legacy admin clients)

Same handler as PUT for clients that can only POST.

Authorizations:
AdminKey
path Parameters
key
required
string
Request Body schema: application/json
required
required
string or integer or boolean or number

Responses

Request samples

Content type
application/json
{
  • "value": "string"
}

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "string",
  • "desc": "string",
  • "data": null,
  • "error": "string"
}

admin-system-messages

Admin → user message pool. Direct sends, listing, and force-delete. Templates / broadcasts / auto-rules ship in P1/P2 — see .claude/plans/admin-system-messages.md.

Direct admin send to one or more users

Inserts one row per userIds entry into system_messages with delivery_status='pending'. The dispatcher cron (bin/sysmsg_dispatcher.php, every minute) picks them up and fires FCM push asynchronously — the HTTP response returns the moment the rows are persisted, NOT after the push completes. Per .claude/plans/admin-system-messages.md v1 does NOT do template substitution here; pass already-rendered text.

Authorizations:
AdminKey
Request Body schema: application/json
required
userIds
Array of integers <int64> [ items <int64 > >= 1 ]

One or more users.id values to deliver the message to.

callerIds
Array of strings

Phone-style caller ids (digits only). Resolved server-side to users.id. Unknown callerids count toward skippedUnknown in the response. Most ops tooling supplies callerids; userIds are for debugging or admin-panel auto-fill flows that already resolved.

title
required
string <= 255 characters

Notification title (already-rendered text; no template substitution at this endpoint).

body
required
string

Notification body (already-rendered text).

scheduledAt
string <date-time>

ISO-8601 timestamp. If omitted, the message is sent as soon as the dispatcher cron next ticks (typically <60s). For delayed sends pass a future timestamp; the cron will pick it up only once scheduled_at <= NOW(). The message is also INVISIBLE in the user inbox until this time (visibility gate).

validUntil
string or null <date-time>

Auto-hide datetime. After this passes the message disappears from the inbox AND is suppressed from the dispatcher (status flips to suppressed). Omit / null = never expires. Must be strictly after scheduledAt or you get INVALID_VALIDITY_WINDOW.

pushEnabled
boolean
Default: true

When false, the row is created but the dispatcher immediately marks it suppressed so no FCM banner fires. The user still sees it in the in-app inbox.

object

Optional structured payload attached to the inbox row. Reserved keys: deepLink, imageUrl, cta. Surfaces back to the user via GET /api/system-messages.

idempotencyKey
string <= 96 characters

Per-request idempotency key. Namespaced internally as admin_direct:{key}:{userId} so a retry of the same request picks up exactly the rows that previously failed without re-sending to the rest.

createdBy
string <= 64 characters

Free-text label persisted in the created_by column of broadcast jobs (and reserved for direct sends in future). Useful for ops triage when many admins share ADMIN_API_KEY.

redirectRoute
string or null <= 128 characters

Opaque deep-link string echoed back to the Flutter client in the FCM data payload as redirectRoute. Caller override wins over the template default. When NULL the dispatcher emits the server-default system_inbox on the wire. Grammar lives client-side — see .claude/decisions/2026-05-12-system-message-redirect-route.md. Common values "paywall:coins_500", "avatar_shop", "coin_shop", "event:halloween_2026", "system_inbox".

Responses

Request samples

Content type
application/json
{
  • "userIds": [
    ],
  • "callerIds": [
    ],
  • "title": "Hoş geldiniz",
  • "body": "Yeni özellikleri keşfetmeye hazır mısınız?",
  • "scheduledAt": "2026-05-15T10:00:00Z",
  • "validUntil": "2019-08-24T14:15:22Z",
  • "pushEnabled": true,
  • "metadata": { },
  • "idempotencyKey": "campaign-2026-04-spring-001",
  • "createdBy": "ops-burak",
  • "redirectRoute": "paywall:coins_500"
}

Response samples

Content type
application/json
{
  • "inserted": 0,
  • "skippedDuplicates": 0,
  • "skippedUnknown": 0,
  • "messageIds": [
    ]
}

Admin pool listing (paginated, filterable)

Returns inbox rows across all users, newest first. All filters are AND-combined; omit any to disable. Use this surface to triage pending/failed rows or audit a specific user's history.

Two paging mechanisms: the keyset cursor (preferred — this table is append-heavy, so offset drifts as rule fires land) and the legacy offset. cursor supersedes offset when both are sent, and the response echoes the offset it actually applied. Filters compose with the cursor, but changing a filter invalidates it — restart paging.

Authorizations:
AdminKey
query Parameters
userId
integer <int64>
Example: userId=1042

Filter to a single recipient (users.id).

ruleId
integer
Example: ruleId=7

Filter to messages produced by a specific auto-rule (P2; null on direct sends).

status
string
Enum: "pending" "delivered" "partial" "failed" "no_token" "suppressed"
Example: status=delivered
since
string <date-time>
Example: since=2026-05-01T00:00:00Z

Only return rows whose created_at >= since.

limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Legacy offset paging. Ignored when cursor is supplied.

cursor
string

Opaque keyset cursor over id DESC from the previous page's nextCursor. Tagged for this endpoint — a cursor minted by another admin list is rejected rather than silently mis-paging.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "limit": 1,
  • "offset": 0,
  • "hasMore": true,
  • "nextCursor": "string"
}

Queue a broadcast to a user segment (all / vip / non_vip)

Inserts ONE row in system_message_broadcast_jobs. The fan-out cron (bin/sysmsg_broadcast_fanout.php, every minute) pages through users matching the audience, calls IVR per user for vip/non_vip filtering, and inserts per-user system_messages rows. Synchronous fan-out is forbidden — see plan invariant #4.

With an idempotencyKey, a duplicate POST returns 200 + replayed=true instead of creating a new job.

Authorizations:
AdminKey
Request Body schema: application/json
required
audience
required
string
Enum: "all" "vip" "non_vip"

v1 segments. vip and non_vip resolve via per-user IVR call during fan-out; for huge audiences a snapshot table will land later (see plan Open Question #1).

title
required
string <= 255 characters

Already-rendered notification title.

body
required
string

Already-rendered notification body.

scheduledAt
string <date-time>

Optional ISO-8601; defaults to now.

object
idempotencyKey
string <= 96 characters

If supplied, a duplicate request returns the existing job (replayed=true).

createdBy
string <= 64 characters

Responses

Request samples

Content type
application/json
{
  • "audience": "all",
  • "title": "string",
  • "body": "string",
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "metadata": { },
  • "idempotencyKey": "string",
  • "createdBy": "string"
}

Response samples

Content type
application/json
{
  • "jobId": 0,
  • "replayed": true,
  • "status": "pending"
}

Fetch a single inbox row by id

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "userId": 0,
  • "callerId": "string",
  • "templateId": 0,
  • "ruleId": 0,
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "redirectRoute": "string",
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "deliveryStatus": "pending",
  • "deliveredAt": "2019-08-24T14:15:22Z",
  • "readAt": "2019-08-24T14:15:22Z",
  • "surfacedAt": "2019-08-24T14:15:22Z",
  • "deletedAt": "2019-08-24T14:15:22Z",
  • "idempotencyKey": "string",
  • "createdAt": "2019-08-24T14:15:22Z"
}

Admin hard-delete (force-removes the row from the user's inbox AND the admin pool)

Differs from the user-facing soft-delete: this DROPs the row entirely rather than setting deleted_at. Use sparingly — rows usually stay forever for audit. Intended for cleaning up errant sends that should not stay in user inboxes.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

Inspect the progress of a broadcast fan-out job

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "audience": "all",
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "idempotencyKey": "string",
  • "cursorUserId": 0,
  • "status": "pending",
  • "insertedCount": 0,
  • "createdBy": "string",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Create a new template

Templates use {{var}} substitution rendered at rule-fire time (auto-rules, P2). The admin panel is expected to lint placeholder keys against a known whitelist before save.

Authorizations:
AdminKey
Request Body schema: application/json
required
templateKey
required
string <= 64 characters

Stable key (e.g. ftu_welcome_10min). Unique across templates.

titleTemplate
required
string <= 255 characters

Supports {{var}} placeholders rendered at fire time.

bodyTemplate
required
string

Supports {{var}} placeholders rendered at fire time.

locale
string <= 8 characters
Default: "tr"
redirectRoute
string or null <= 128 characters

Default deep-link for every message rendered from this template. Per-instance callers (direct send, announcement create) can override; rule-fired messages inherit verbatim. Opaque string — see .claude/decisions/2026-05-12-system-message-redirect-route.md.

parentTemplateId
integer or null

Non-null makes this row a pool variant of the referenced head template. One nesting level only — the parent must exist and must itself be a head (parentTemplateId null), otherwise 422 VALIDATION_FAILED.

variantNo
integer or null >= 1

1-based order within the pool; unique per head. Null on heads.

Responses

Request samples

Content type
application/json
{
  • "templateKey": "string",
  • "titleTemplate": "string",
  • "bodyTemplate": "string",
  • "locale": "tr",
  • "redirectRoute": "avatar_shop",
  • "parentTemplateId": 0,
  • "variantNo": 1
}

Response samples

Content type
application/json
{
  • "id": 0
}

List templates (paginated, optionally filtered by activeOnly)

Newest first (id DESC). Two paging mechanisms: the keyset cursor (preferred) and the legacy offset; cursor supersedes offset when both are sent.

Authorizations:
AdminKey
query Parameters
activeOnly
boolean
Example: activeOnly=true
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Legacy offset paging. Ignored when cursor is supplied.

cursor
string

Opaque keyset cursor over id DESC from the previous page's nextCursor. Tagged for this endpoint — a cursor minted by another admin list is rejected rather than silently mis-paging.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "limit": 0,
  • "offset": 0,
  • "hasMore": true,
  • "nextCursor": "string"
}

Fetch a single template by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "templateKey": "string",
  • "titleTemplate": "string",
  • "bodyTemplate": "string",
  • "locale": "string",
  • "redirectRoute": "string",
  • "isActive": true,
  • "parentTemplateId": 0,
  • "variantNo": 0,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Partial update of a template (PATCH-style — pass only changed fields)

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
titleTemplate
string <= 255 characters
bodyTemplate
string
locale
string <= 8 characters
isActive
boolean
redirectRoute
string or null <= 128 characters

Pass null to clear the template default.

parentTemplateId
integer or null

Attach to (or with null, detach from) a pool head. Same one-nesting-level rule as create.

variantNo
integer or null >= 1

Responses

Request samples

Content type
application/json
{
  • "titleTemplate": "string",
  • "bodyTemplate": "string",
  • "locale": "string",
  • "isActive": true,
  • "redirectRoute": "string",
  • "parentTemplateId": 0,
  • "variantNo": 1
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "templateKey": "string",
  • "titleTemplate": "string",
  • "bodyTemplate": "string",
  • "locale": "string",
  • "redirectRoute": "string",
  • "isActive": true,
  • "parentTemplateId": 0,
  • "variantNo": 0,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Soft-disable a template (sets is_active=0; row stays for audit/joins)

Hard delete is intentionally not supported. Existing system_messages.template_id rows would dangle. To "remove" a template, soft-disable it; rules referencing it stop firing.

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

Create an auto-fire rule

Rules tie a template to a trigger event + audience. The rule engine renders the template at fire time and inserts a system_messages row for each matched user. Idempotency on (rule_id, user_id, fired_period) — the engine cannot double-fire.

Authorizations:
AdminKey
Request Body schema: application/json
required
ruleKey
required
string <= 64 characters

Stable handle for ops debugging. Unique across rules.

templateId
required
integer

Reference to an active system_message_templates.id.

triggerEvent
required
string
Enum: "user_registered" "user_first_login" "user_returned_after_idle" "scheduled_recurring"
  • user_registered / user_first_login — fire once per user from the auth flow's post-commit hook. (In v1 these are the same moment; both events fire when a user authenticates for the first time.)
  • user_returned_after_idle — daily SQL match. Requires triggerParams.idleDays (1-3650).
  • scheduled_recurring — per-period fan-out. Requires triggerParams.period ∈ {daily, weekly, monthly}.
object or null

Per-event params. Required for idle/recurring; optional for inline events (kept null typically). Examples: {"idleDays":14}, {"period":"weekly"}.

object or null

{"segment":"all|vip|non_vip"}. NULL = all active users.

delaySeconds
integer >= 0
Default: 0

Applied at fire time. scheduled_at = fired_at + delaySeconds. Used to implement "10 minutes after registration" style rules.

targetKind
string
Default: "personal"
Enum: "personal" "announcement"

personal — fire produces one system_messages row per matched user (legacy behaviour, default). announcement — fire produces ONE audience-wide announcement per period (catchup + valid_until automatic). Only valid with trigger_event=scheduled_recurring; pairing with any per-user trigger returns 400 INVALID_RULE_TARGET.

deliveryChannel
string
Default: "inbox_push"
Enum: "inbox_push" "push_only"

inbox_push — an inbox message plus an FCM push (the default). push_only — an FCM banner only; the row does not appear in the user's inbox, and the push payload is sent with type=push_only and WITHOUT the message-box keys. An invalid value returns 400 INVALID_PAYLOAD.

Responses

Request samples

Content type
application/json
{
  • "ruleKey": "string",
  • "templateId": 0,
  • "triggerEvent": "user_registered",
  • "triggerParams": { },
  • "audienceFilter": { },
  • "delaySeconds": 0,
  • "targetKind": "personal",
  • "deliveryChannel": "inbox_push"
}

Response samples

Content type
application/json
{
  • "id": 0
}

List auto-fire rules (paginated, optionally filtered by activeOnly)

Newest first (id DESC). Two paging mechanisms: the keyset cursor (preferred) and the legacy offset; cursor supersedes offset when both are sent.

Authorizations:
AdminKey
query Parameters
activeOnly
boolean
Example: activeOnly=true
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Legacy offset paging. Ignored when cursor is supplied.

cursor
string

Opaque keyset cursor over id DESC from the previous page's nextCursor. Tagged for this endpoint — a cursor minted by another admin list is rejected rather than silently mis-paging.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "limit": 0,
  • "offset": 0,
  • "hasMore": true,
  • "nextCursor": "string"
}

Fetch a single rule by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "ruleKey": "string",
  • "templateId": 0,
  • "triggerEvent": "user_registered",
  • "triggerParams": { },
  • "audienceFilter": { },
  • "targetKind": "personal",
  • "deliveryChannel": "inbox_push",
  • "delaySeconds": 0,
  • "isActive": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Partial update of a rule

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
templateId
integer
triggerEvent
string
Enum: "user_registered" "user_first_login" "user_returned_after_idle" "scheduled_recurring"
object or null
object or null
delaySeconds
integer >= 0
isActive
boolean
targetKind
string
Enum: "personal" "announcement"

See create-request notes. Switching an active rule from personal to announcement while it has a non-recurring trigger returns 400 INVALID_RULE_TARGET.

deliveryChannel
string
Enum: "inbox_push" "push_only"

See create-request notes. Invalid value → 400 INVALID_PAYLOAD.

Responses

Request samples

Content type
application/json
{
  • "templateId": 0,
  • "triggerEvent": "user_registered",
  • "triggerParams": { },
  • "audienceFilter": { },
  • "delaySeconds": 0,
  • "isActive": true,
  • "targetKind": "personal",
  • "deliveryChannel": "inbox_push"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "ruleKey": "string",
  • "templateId": 0,
  • "triggerEvent": "user_registered",
  • "triggerParams": { },
  • "audienceFilter": { },
  • "targetKind": "personal",
  • "deliveryChannel": "inbox_push",
  • "delaySeconds": 0,
  • "isActive": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Soft-disable a rule (sets is_active=0)

Hard delete is intentionally not supported. Existing system_messages.rule_id rows would dangle.

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

List the {{var}} injection key registry

Single source of truth for the placeholder keys available to admin- authored templates. The admin panel consumes this to render chip- based variable pickers (Templates / Compose / Automations) and to warn when a template references a key that the chosen trigger does not supply.

Scopes:

  • global — always available (date, weekday, month, year).
  • user — available wherever the renderer knows a recipient.
  • trigger— only injected when a rule with that trigger fires.
Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "byScope": {
    },
  • "byTrigger": {
    },
  • "triggers": [
    ],
  • "personas": [
    ],
  • "segments": [
    ]
}

Create an announcement

Authors a broadcast definition with a validity window, audience filter, and optional FCM push toggle. Per-user inbox state is materialized lazily (catchup on inbox fetch) and eagerly (dispatcher at scheduled_at). New users registering after scheduled_at see the message on their first inbox fetch as long as valid_until has not passed.

Idempotency: pass idempotencyKey; re-submission returns the existing announcement (replayed=true) instead of duplicating.

Authorizations:
AdminKey
Request Body schema: application/json
required
audienceType
required
string
Enum: "all" "vip" "non_vip" "specific_users"

all — every active user. vip / non_vip — resolved per-user via live IVR call (fail-closed: IVR error excludes the user). specific_users — caller MUST supply recipientUserIds and/or recipientCallerIds.

title
string <= 255 characters

Push banner title. Inbox sender label is a separate [SYSTEM] constant from system_config.

body
string <= 2000 characters
scheduledAt
string <date-time>

Visibility gate. The announcement is invisible in inboxes AND no push fires until this time. Defaults to NOW() when omitted.

validUntil
string or null <date-time>

Auto-hide datetime. After this passes the announcement disappears from every inbox (recipients and never-seen users alike). Omit or send null for "never expires".

pushEnabled
boolean
Default: true

When false, the message appears in the in-app inbox but no FCM banner is fired.

object or null
recipientUserIds
Array of integers

Required when audienceType=specific_users (alongside or instead of recipientCallerIds).

recipientCallerIds
Array of strings

Digits-only callerids; resolved server-side to user ids. Unknown ids are silently dropped.

idempotencyKey
string <= 128 characters

Re-submitting with the same key returns the original announcement instead of creating a duplicate.

createdBy
string <= 64 characters

Free-text admin label for audit; defaults to admin.

templateId
integer or null <int64>

FK to system_message_templates. When set, the template's title_template / body_template override the caller-supplied title / body. Per-recipient render (incl. {{firstName}} and other user-context placeholders) happens at materialize time, not at create time. Errors: TEMPLATE_NOT_FOUND (404), INACTIVE_TEMPLATE (409).

object or null

Static context map merged into the per-recipient render context (admin/rule-supplied). User-specific keys like firstName are injected automatically — only put non-user values here (campaign name, periodKey, etc.).

redirectRoute
string or null <= 128 characters

Per-blast deep-link override echoed back to the Flutter client in the FCM data payload as redirectRoute. When omitted, inherits the template's redirect_route; when both are null, the dispatcher emits the server-default system_inbox on the wire. Opaque string — see .claude/decisions/2026-05-12-system-message-redirect-route.md.

Responses

Request samples

Content type
application/json
{
  • "audienceType": "all",
  • "title": "string",
  • "body": "string",
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "validUntil": "2019-08-24T14:15:22Z",
  • "pushEnabled": true,
  • "metadata": { },
  • "recipientUserIds": [
    ],
  • "recipientCallerIds": [
    ],
  • "idempotencyKey": "string",
  • "createdBy": "string",
  • "templateId": 0,
  • "variables": { },
  • "redirectRoute": "event:halloween_2026"
}

Response samples

Content type
application/json
{
  • "announcementId": 0,
  • "replayed": true
}

List announcements (admin pool view)

Newest first (id DESC). Two paging mechanisms: the keyset cursor (preferred — this table grows per broadcast, so offset drifts) and the legacy offset; cursor supersedes offset when both are sent, and the response echoes the offset it actually applied. Filters compose with the cursor, but changing a filter invalidates it — restart paging.

Authorizations:
AdminKey
query Parameters
audienceType
string
Enum: "all" "vip" "non_vip" "specific_users"
pushStatus
string
Enum: "pending" "dispatching" "completed" "disabled"
since
string <date-time>

Filter to rows with created_at >= since.

activeOnly
string
Value: "1"

Pass 1 to filter to currently-visible rows (scheduled_at <= NOW() AND valid_until > NOW()).

limit
integer [ 1 .. 200 ]
Default: 30
offset
integer >= 0
Default: 0

Legacy offset paging. Ignored when cursor is supplied.

cursor
string

Opaque keyset cursor over id DESC from the previous page's nextCursor. Tagged for this endpoint — a cursor minted by another admin list is rejected rather than silently mis-paging.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "limit": 0,
  • "offset": 0,
  • "hasMore": true,
  • "nextCursor": "string"
}

Get a single announcement (with push status counts and recipient list when specific_users)

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "redirect_route": "string",
  • "audience_type": "all",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "valid_until": "2019-08-24T14:15:22Z",
  • "push_enabled": 0,
  • "push_status": "pending",
  • "push_cursor_user_id": 0,
  • "idempotency_key": "string",
  • "created_by": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "push_status_counts": {
    },
  • "recipient_user_ids": [
    ]
}

Patch the two ops-editable fields (`validUntil`, `pushEnabled`)

Use this to extend a promo window mid-flight, or to kill an in-flight push (pushEnabled=false flips push_status to disabled and stops the dispatcher from claiming new state rows).

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>
Request Body schema: application/json
required
validUntil
string or null <date-time>

Send null to clear (never expires).

pushEnabled
boolean

Setting this to false also flips push_status to disabled so the eager dispatcher stops claiming new state rows for this announcement.

redirectRoute
string or null <= 128 characters

Send null to clear. Affects only pushes fired after the patch — already-dispatched rows kept their stored value.

Responses

Request samples

Content type
application/json
{
  • "validUntil": "2019-08-24T14:15:22Z",
  • "pushEnabled": true,
  • "redirectRoute": "string"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "redirect_route": "string",
  • "audience_type": "all",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "valid_until": "2019-08-24T14:15:22Z",
  • "push_enabled": 0,
  • "push_status": "pending",
  • "push_cursor_user_id": 0,
  • "idempotency_key": "string",
  • "created_by": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "push_status_counts": {
    },
  • "recipient_user_ids": [
    ]
}

Hard-delete an announcement (cascades to recipients + per-user state)

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "deleted": true
}

Cancel a scheduled or in-flight announcement

First-class cancellation. Stamps cancelled_at / cancelled_by for audit, sets valid_until=NOW() (hides the message from every inbox), and push_enabled=0, push_status=disabled (stops the dispatcher).

Idempotent. Re-cancelling an already-cancelled row returns the same snapshot without changing cancelled_at.

Rejected with ALREADY_COMPLETED once push_status='completed' — once the broadcast has fully fanned out and pushed, use PATCH /admin/announcements/{id} with validUntil instead to retroactively hide the inbox row.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>
Request Body schema: application/json
optional
cancelledBy
string

Identifier of the operator performing the cancel; defaults to "admin".

Responses

Request samples

Content type
application/json
{
  • "cancelledBy": "ops-berkk"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "redirect_route": "string",
  • "audience_type": "all",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "valid_until": "2019-08-24T14:15:22Z",
  • "push_enabled": 0,
  • "push_status": "pending",
  • "push_cursor_user_id": 0,
  • "idempotency_key": "string",
  • "created_by": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "push_status_counts": {
    },
  • "recipient_user_ids": [
    ]
}

Campaign-analytics funnel for one announcement

Read-only aggregation over user_announcement_state for the given announcement. Returns the recipient → surfaced → opened funnel plus push-status splits and time-to-read percentiles.

recipients counts state rows materialized so far — eagerly via the push dispatcher's Phase 1 fan-out, and lazily via the catchup service on each inbox open. For broad audiences (all / vip / non_vip) this grows over time as users open their inbox, so openRate is "of people reached so far" not "of the total addressable audience".

surfaced is the count of rows whose surfaced_at is set — i.e. the row was returned by the inbox-list endpoint at least once. opened is the count with read_at set (user explicitly tapped the message in the thread page).

p50MinutesToRead / p90MinutesToRead are percentiles of (read_at - COALESCE(surfaced_at, first_seen_at)) in whole minutes, computed only over rows where read_at IS NOT NULL. Null when no opens have happened yet.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "announcementId": 0,
  • "audienceType": "all",
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "validUntil": "2019-08-24T14:15:22Z",
  • "pushEnabled": true,
  • "pushStatus": "pending",
  • "cancelledAt": "2019-08-24T14:15:22Z",
  • "funnel": {
    }
}

admin-push-automations

Push-automation behaviour: the 8-key system_config family (quiet hours, retention cap, spacers, template pooling) plus a one-call 24h-schedule read model backing the tp_panel Zamanlama page. All hours on the wire are UTC.

Read the push-behaviour config family

Returns the 8-key push-behaviour system_config family as typed values (quiet hours, retention daily cap, min-interval spacers, template-pool switches). Missing keys fall back to their seeded defaults. All minutes are UTC minute-of-day — the panel converts to GMT+3.

All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "config": {
    }
}

Partially update the push-behaviour config family

Accepts any subset of the config keys. Everything is validated before anything is written — one bad key rejects the whole body with 422 VALIDATION_FAILED and leaves the config untouched. Values are stored as int strings in system_config (updated_by = 'admin'). Returns the fresh config.

Authorizations:
AdminKey
Request Body schema: application/json
required
quietHoursEnabled
boolean
quietHoursStartMinute
integer [ 0 .. 1439 ]
quietHoursEndMinute
integer [ 0 .. 1439 ]
maxCronPerUserPerDay
integer >= 0
minIntervalSeconds
integer >= 0
retentionMinIntervalSeconds
integer >= 0
poolEnabled
boolean
poolNoRepeatDepth
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "poolNoRepeatDepth": 2,
  • "quietHoursStartMinute": 1200
}

Response samples

Content type
application/json
{
  • "config": {
    }
}

One-call 24h-schedule read model (Zamanlama page)

Everything the tp_panel Zamanlama page needs in one call:

  • config — the same 8-key family as GET …/config;
  • rules[] — every active personal rule (announcements are excluded) with its send window (hours band from trigger_params, resetRelative for daily_wheel_expiring, or null = every tick), inline flag, delivery channel, daily-cap flag, and its content pool (head + active variants, each with raw templates AND server-rendered sampleTitle/sampleBody);
  • warnings[] — non-fatal problems (e.g. unparseable trigger_params renders that rule's window as null instead of failing the read).

All hours are UTC; the panel converts them to its selected display timezone.

Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "config": {
    },
  • "rules": [
    ],
  • "warnings": [
    ]
}