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).
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.
| refreshToken required | string The refresh token from the last token pair. |
{- "refreshToken": "rt_9f3c1a7b2e5d4086b1c3a9f7e2d5c840b6a1938f4e7c2d05"
}{- "status": "OK",
- "message": "Token yenilendi.",
- "data": {
- "jwtToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI5MDUzMjEyMzQ1NjcifQ.7sK3",
- "refreshToken": "rt_4b8e1d6c0a92f37e5c1b8d40a7f2e6c93b05d1a8f4e7290c",
- "expiresAtMs": 1785328200000,
- "refreshExpiresAtMs": 1787918400000,
- "expiresAt": "2026-07-27T12:30:00Z",
- "refreshExpiresAt": "2026-08-26T12:00:00Z"
}
}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.
| refreshToken required | string The refresh token from the last token pair. |
{- "refreshToken": "rt_9f3c1a7b2e5d4086b1c3a9f7e2d5c840b6a1938f4e7c2d05"
}{- "status": "OK",
- "message": "Çıkış yapıldı.",
- "data": {
- "revoked": true
}
}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).
| idToken required | string Google ID token (JWT) issued by the |
| displayName | string or null Client-supplied hint, used as |
| deviceId | string Stable client-side device identifier (e.g. a UUID persisted in
local storage). Used as the |
| deviceType | string Free-form (e.g. |
| firebaseToken | string FCM registration token from
|
| versionNo | string App version string for triage (e.g. |
| appVersion | string Alias for |
{- "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjBhMWIyYyJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20ifQ.sig"
}{- "userId": "+999102938475610",
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIrOTk5MTAyOTM4NDc1NjEwIn0.7sK3",
- "refreshToken": "rt_9f3c1a7b2e5d4086b1c3a9f7e2d5c840b6a1938f4e7c2d05",
- "tokenType": "Bearer",
- "expiresAtMs": 1785328200000,
- "refreshExpiresAtMs": 1787918400000,
- "isNewUser": true,
- "profile": {
- "email": "ayse@example.com",
- "emailVerified": true,
- "displayName": "Ayşe",
}
}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).
| identityToken required | string Apple identity token (JWT) issued by the |
| 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 |
| deviceId | string Stable client-side device identifier (e.g. a UUID persisted in
local storage). Used as the |
| deviceType | string Free-form (e.g. |
| firebaseToken | string FCM registration token from
|
| versionNo | string App version string for triage (e.g. |
| appVersion | string Alias for |
{- "identityToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjhkOWYxYSJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIn0.sig",
- "givenName": "Ayşe",
- "familyName": "Yılmaz"
}{- "userId": "+999504837261950",
- "uuid": "9b2d4f60-1c3a-4e78-9f05-2a7c6d8e1b34",
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIrOTk5NTA0ODM3MjYxOTUwIn0.7sK3",
- "refreshToken": "rt_2c7e9a1f5b3d8046e2a9c4f7b1d5e830a6f2947c3e8b1d0a",
- "tokenType": "Bearer",
- "expiresAtMs": 1785328200000,
- "refreshExpiresAtMs": 1787918400000,
- "isNewUser": true,
- "profile": {
- "email": "ayse@example.com",
- "emailVerified": true,
- "displayName": "Ayşe Yılmaz",
- "isPrivateRelay": false
}
}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.
| telefon required | string Phone number, E.164-style without |
| phone | string Alias for |
{- "telefon": "905321234567"
}{- "status": "OK",
- "message": "OTP gönderildi.",
- "expiresAt": "2026-07-27 12:05:00",
- "resendAvailableAtMs": 1785326460000,
- "debugOtp": null,
- "serviceResponse": "queued:1"
}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.
| telefon required | string |
| phone | string Alias for |
| otp required | string 6-digit code. |
| kod | string Alias for |
| deviceId | string |
| deviceType | string Free-form (e.g. |
| firebaseToken | string |
| versionNo | string |
| appVersion | string Alias for |
{- "telefon": "905321234567",
- "otp": "481902",
- "deviceId": "a3f1c9d2e8b4",
- "deviceType": "android",
- "versionNo": "2.14.0"
}{- "status": "OK",
- "message": "Giriş başarılı.",
- "data": {
- "user": {
- "id": 1042,
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
- "telefon": "905321234567",
- "callerid": "905321234567",
- "userName": "gece_kusu",
- "fullName": "Ayşe",
- "gender": "female",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}, - "activeBanner": null,
- "profileImageStatus": "approved",
- "pictureSource": "photo",
- "selectedProfileImageId": 8842
}, - "tokens": {
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI5MDUzMjEyMzQ1NjcifQ.7sK3",
- "sessionToken": "sess_3c9a1f5b2e8d40761a4c7f2b9d6e830a",
- "refreshToken": "rt_9f3c1a7b2e5d4086b1c3a9f7e2d5c840b6a1938f4e7c2d05",
- "jwtToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI5MDUzMjEyMzQ1NjcifQ.7sK3",
- "expiresAt": "2026-07-27 12:30:00",
- "refreshExpiresAt": "2026-08-26 12:00:00"
}, - "isNewUser": false
}
}Same handler as /otpSmsCheck. Legacy alias.
| telefon required | string |
| phone | string Alias for |
| otp required | string 6-digit code. |
| kod | string Alias for |
| deviceId | string |
| deviceType | string Free-form (e.g. |
| firebaseToken | string |
| versionNo | string |
| appVersion | string Alias for |
{- "telefon": "905321234567",
- "otp": "481902",
- "deviceId": "a3f1c9d2e8b4",
- "deviceType": "android",
- "versionNo": "2.14.0"
}{- "status": "OK",
- "message": "Giriş başarılı.",
- "data": {
- "user": {
- "id": 1042,
- "callerid": "905321234567",
- "userName": "gece_kusu",
- "activeFrame": null
}, - "tokens": {
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI5MDUzMjEyMzQ1NjcifQ.7sK3",
- "refreshToken": "rt_9f3c1a7b2e5d4086b1c3a9f7e2d5c840b6a1938f4e7c2d05",
- "expiresAt": "2026-07-27 12:30:00"
}, - "isNewUser": false
}
}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.
| 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 |
{- "userId": 1042,
- "username": "gece_kusu",
- "full_name": "Ayşe",
- "gender": "female",
- "bio": "Bugün buradayız."
}{- "status": "OK",
- "data": {
- "userId": 1042,
- "userKey": "905321234567",
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
- "callerId": "905321234567",
- "displayName": "Ayşe",
- "fullName": "Ayşe",
- "username": "gece_kusu",
- "gender": "female",
- "secondLanguage": "en",
- "birthDate": "1998-04-12",
- "avatarId": 31,
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": null
}, - "activeBanner": null,
- "profileImageUrl": null
}
}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.
| userId required | integer |
| full_name | string |
| fullName | string Alias. |
| username | string |
| bio | string <= 500 characters |
| about | string Alias for |
| birth_date | string or null YYYY-MM-DD. Optional; send an explicit |
| 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. |
{- "userId": 1042,
- "full_name": "Ayşe",
- "bio": "Bugün buradayız.",
- "birth_date": "1998-04-12",
- "gender": "female"
}{- "status": "OK",
- "message": "Profil güncellendi.",
- "data": {
- "userId": 1042,
- "userKey": "905321234567",
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
- "callerId": "905321234567",
- "displayName": "Ayşe",
- "fullName": "Ayşe",
- "username": "gece_kusu",
- "gender": "female",
- "secondLanguage": "en",
- "birthDate": "1998-04-12",
- "avatarId": 31,
- "activeFrame": null,
- "activeBanner": null,
- "profileImageStatus": "approved",
- "pictureSource": "photo",
- "selectedProfileImageId": 4210
}
}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.
| name required | string |
{- "name": "gece_kusu"
}{- "approved": true,
- "available": true
}Sets users.status='deleted', is_deleted=1. Idempotent: a second
call returns OK with "Hesap zaten silinmiş". Either userId or
callerid is required.
| userId | integer |
| callerid | string |
| telefon | string Alias for |
| phone | string Alias for |
{- "callerid": "905321234567"
}{- "status": "OK",
- "message": "Hesap silindi"
}Returns { countryCode, dialCode } from a hardcoded ISO-2 → dial-code
map. Unknown codes default to +90.
| countryCode | string ISO-2 (e.g. |
| country_code | string Alias. |
{- "countryCode": "US"
}{- "status": "OK",
- "data": {
- "countryCode": "US",
- "dialCode": "+1"
}
}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.
| 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. |
{- "userId": "905329876543"
}{- "success": true,
- "message": "OK",
- "data": {
- "data": [
- {
- "userId": 1042,
- "userKey": "905329876543",
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
- "fullName": "Mehmet",
- "username": "Mehmet",
- "displayName": "Mehmet",
- "bio": "Sohbete katıl.",
- "birthDate": "1996-11-03",
- "age": 29,
- "gender": "male",
- "secondLanguage": "en",
- "activeFrame": null,
- "activeBanner": null,
- "rate": 4.6,
- "interests": [
], - "vip": {
- "isVip": true,
- "tier": "gold",
- "expiresAtMs": 1787918400000,
- "source": "cache",
- "stale": false
}
}
]
}
}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.
| callerId | string |
| callerid | string Alias. |
| userId | integer |
| user_id | integer Alias. |
{- "callerId": "905321234567"
}{- "status": "OK",
- "data": {
- "userId": 1042,
- "userKey": "905321234567",
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
- "callerId": "905321234567",
- "status": "active",
- "displayName": "Ayşe",
- "fullName": "Ayşe",
- "username": "gece_kusu",
- "name": "Ayşe",
- "gender": "female",
- "secondLanguage": "en",
- "birthDate": "1998-04-12",
- "age": 28,
- "avatarId": 31,
- "bio": "Bugün buradayız.",
- "about": "Bugün buradayız.",
- "rating": 4.8,
- "interests": [
], - "createdAt": "2026-02-11 09:14:22"
}
}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.
| callerId required | string Target user |
{- "userId": 1042,
- "userKey": "905329876543",
- "callerId": "905329876543",
- "displayName": "Mehmet",
- "fullName": "Mehmet",
- "username": "Mehmet",
- "aliasName": "İş — Mehmet",
- "avatarId": 44,
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": null
}, - "activeBanner": null,
}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 aliasPass 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.
?include=)Sections are opt-in via a comma-separated ?include= query param.
Valid section names: core, profilePicture, cosmetics, gifts,
wallet, relationship.
core: always returned, even if omitted from include.wallet; private extras of profilePicture
and cosmetics) are silently omitted in public mode — no error.relationship) is silently omitted in
private mode.returnedSections in the response lists exactly which sections are
present in sections.
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.
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.
| callerId required | string Target user |
| include | string Example: include=core,profilePicture,relationship Comma-separated allowlist of section names to include.
Valid values: |
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": [
- "core",
- "profilePicture",
- "cosmetics",
- "gifts",
- "wallet"
], - "sections": {
- "core": {
- "userId": 12345,
- "userKey": "u_8f3a1c92",
- "callerId": "905557771000",
- "displayName": "Elif",
- "name": "Elif",
- "fullName": "Elif Yılmaz",
- "username": "elif",
- "gender": "female",
- "secondLanguage": "en",
- "birthDate": "2001-04-18",
- "age": 25,
- "bio": "Kahve ve müzik.",
- "rating": 4.8,
- "heartCount": 42,
- "interests": [
- {
- "id": 3,
- "name": "Müzik",
- "icon": "🎵",
- "iconUrl": "/uploads/interests/music.png"
}, - {
- "id": 9,
- "name": "Seyahat",
- "icon": "✈️",
- "iconUrl": "/uploads/interests/travel.png"
}
], - "vip": {
- "isVip": true,
- "tier": "gold",
- "expiresAtMs": 1781308800000,
- "source": "live",
- "stale": false
}, - "createdAtMs": 1736510400000
}, - "profilePicture": {
- "avatarId": 7,
- "avatarUrl": "/uploads/cosmetics/avatars/7_female.png",
- "activeFrame": {
- "id": 21,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": null
}, - "activeBanner": {
- "id": 31,
- "imageUrl": "/uploads/cosmetics/banners/red-carpet.webp",
- "animationUrl": "/uploads/cosmetics/banners/animations/red-carpet.svga"
}, - "moderationState": {
- "profileImageStatus": "pending",
- "pictureSource": "photo",
- "selectedProfileImageId": 88
}
}, - "cosmetics": {
- "active": {
- "frame": {
- "id": 21,
- "type": "frame",
- "name": "Altın Çerçeve",
- "displayName": "Altın Çerçeve",
- "internalName": "gold_frame_v2",
- "imageUrl": "/uploads/cosmetics/frames/21_gold.png",
- "price": 500,
- "tier": "epic",
- "isDefault": false,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": null,
- "shopItemId": 42,
- "labels": [
- "featured"
], - "source": "purchase",
- "acquiredAt": "2026-06-12 12:34:56",
- "isOwned": true,
- "isActive": true
}, - "background": null,
- "avatar": {
- "id": 7,
- "type": "avatar",
- "name": "Klasik Kadın",
- "displayName": "Klasik Kadın",
- "internalName": "classic_female",
- "gender": "female",
- "imageUrl": "/uploads/cosmetics/avatars/7_female.png",
- "price": 0,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 0,
- "attrs": {
- "gender": "female"
}, - "shopItemId": null,
- "labels": [ ],
- "source": "default",
- "acquiredAt": "2026-01-10 12:00:00",
- "isOwned": true,
- "isActive": true
}
}, - "owned": {
- "frame": [
- {
- "id": 21,
- "type": "frame",
- "name": "Altın Çerçeve",
- "displayName": "Altın Çerçeve",
- "internalName": "gold_frame_v2",
- "imageUrl": "/uploads/cosmetics/frames/21_gold.png",
- "price": 500,
- "tier": "epic",
- "isDefault": false,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": null,
- "shopItemId": 42,
- "labels": [
- "featured"
], - "source": "purchase",
- "acquiredAt": "2026-06-12 12:34:56",
- "isOwned": true,
- "isActive": true
}
], - "background": [ ],
- "avatar": [
- {
- "id": 7,
- "type": "avatar",
- "name": "Klasik Kadın",
- "displayName": "Klasik Kadın",
- "internalName": "classic_female",
- "gender": "female",
- "imageUrl": "/uploads/cosmetics/avatars/7_female.png",
- "price": 0,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 0,
- "attrs": {
- "gender": "female"
}, - "shopItemId": null,
- "labels": [ ],
- "source": "default",
- "acquiredAt": "2026-01-10 12:00:00",
- "isOwned": true,
- "isActive": true
}
]
}
}, - "gifts": {
- "inventory": [
- {
- "giftId": 7,
- "internalName": "rose",
- "displayName": "Gül",
- "imageUrl": "/uploads/gifts/rose.webp",
- "tier": "gold",
- "count": 12
}, - {
- "giftId": 3,
- "internalName": "heart",
- "displayName": "Kalp",
- "imageUrl": "/uploads/gifts/heart.webp",
- "tier": "common",
- "count": 5
}
], - "showcase": [
- {
- "giftId": 7,
- "internalName": "rose",
- "displayName": "Gül",
- "imageUrl": "/uploads/gifts/rose.webp",
- "tier": "gold",
- "count": 12
}, - {
- "giftId": 3,
- "internalName": "heart",
- "displayName": "Kalp",
- "imageUrl": "/uploads/gifts/heart.webp",
- "tier": "common",
- "count": 5
}
]
}, - "wallet": {
- "coinBalance": 1500
}
}
}Returns active rows from interests, sorted by sort_order, id.
name is localized via interest_localized_info: requested locale →
en → interests.name (legacy canonical column). Supplying neither
?locale= nor Accept-Language yields English — wire shape is
unchanged, so pre-localization clients keep working.
| locale | string Enum: "tr" "en" Example: locale=tr Overrides |
| Accept-Language | string Example: tr First segment is parsed; region tags ( |
{- "status": "OK",
- "data": [
- {
- "id": 3,
- "icon": "music",
- "name": "Müzik"
}, - {
- "id": 7,
- "icon": "travel",
- "name": "Seyahat"
}
]
}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 } }).
| callerId required | string |
| gender required | string Enum: "male" "female" "other" |
| secondLanguage required | string |
| interests | Array of integers Optional. Omitted or |
{- "callerId": "905321234567",
- "gender": "female",
- "secondLanguage": "en",
- "interests": [
- 3,
- 7,
- 12
]
}{- "status": "OK",
- "message": "Profil kaydedildi."
}name is localized the same way as /get-interests
(requested locale → en → interests.name).
| callerId required | string Example: callerId=905551112233 |
| locale | string Enum: "tr" "en" Example: locale=tr Overrides |
| Accept-Language | string Example: tr |
{- "status": "OK",
- "data": [
]
}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.
| callerId required | string Example: callerId=905551112233 |
| interests required | Array of integers
|
{- "interests": [
- 3,
- 7,
- 12
]
}{- "status": "OK",
- "message": "İlgi alanları güncellendi."
}callerId in query, secondLanguage in body.
| callerId required | string Example: callerId=905551112233 |
| secondLanguage required | string |
{- "secondLanguage": "en"
}{- "status": "OK",
- "message": "İkinci dil güncellendi."
}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.
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.
{- "dashboard": {
- "totalRooms": "3",
- "activeGroupRooms": "2",
- "activeVipRooms": "1",
- "activeGroupRoomsUsers": "38",
- "activeVipRoomsUsers": "9",
- "totalUsers": "47",
- "totalListeners": "38",
- "totalSpeakers": "9",
- "totalSeats": "24",
- "generatedAt": "2026-06-16T13:00:00Z"
}
}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.
{- "generatedAt": "2026-06-16T13:00:00Z",
- "rooms": [
- {
- "roomId": "550e8400-e29b-41d4-a716-446655440000",
- "roomName": "Gece Sohbeti",
- "type": "group",
- "category": "general",
- "hostUserId": 42,
- "totalUserCount": 12,
- "listenerCount": 8,
- "speakerCount": 4,
- "seatCount": 4,
- "createdAt": "2026-06-16T12:30:00Z"
}
]
}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).
{- "totals": {
- "playersInLobby": 88,
- "playersInGame": 412,
- "playersTotal": 500,
- "lobbyRooms": 31,
- "playingRooms": 42,
- "liveRooms": 73
}, - "byGameType": {
- "okey": {
- "gameType": "okey",
- "playersInLobby": 0,
- "playersInGame": 0,
- "playersTotal": 0,
- "lobbyRooms": 0,
- "playingRooms": 0,
- "liveRooms": 0
}, - "umo": {
- "gameType": "umo",
- "playersInLobby": 41,
- "playersInGame": 180,
- "playersTotal": 221,
- "lobbyRooms": 12,
- "playingRooms": 16,
- "liveRooms": 28
}
}, - "generatedAtMs": 1750250000000
}| 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 |
{- "rooms": [
- {
- "id": "room_6812a3b4c5d6e7.12345678",
- "shareCode": "kx9a2bc",
- "rtcRoomId": "string",
- "zegoRoomID": "string",
- "title": "string",
- "imageUrl": "string",
- "listenerCount": 0,
- "speakerCount": 0,
- "totalUserCount": 15,
- "topUserAvatarUrls": [
- "string"
], - "topUsers": [
- {
- "avatarUrl": "string",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}
}
], - "isVideoEnabled": true,
- "seatCount": 0,
- "color": "#DC11FF",
- "isFeatured": true,
- "type": "group",
- "category": "string",
- "wheel": {
- "wheelId": 0,
- "code": "main_wheel_v1"
}, - "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0,
- "seats": [
- {
- "seatIndex": 0,
- "userId": "string",
- "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
- "rtcUserId": "string",
- "zegoUserId": "string",
- "expiresAtMs": 0
}
], - "myMembership": {
- "role": "listener",
- "seatIndex": 0,
- "mayPublish": true,
- "seatExpiresAtMs": 0,
- "forcedListenerOnRejoin": true
}
}
], - "total": 0,
- "page": 0,
- "limit": 0
}| name | string <= 255 characters The room name chosen by the user ( |
| title | string <= 255 characters Backwards-compatible field; used when |
| rtcRoomId | string RTC (LiveKit) room name — the |
| 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
|
{- "name": "string",
- "title": "string",
- "rtcRoomId": "string",
- "zegoRoomID": "string",
- "type": "group",
- "category": "general",
- "seatCount": 4,
- "isVideoEnabled": false,
- "imageUrl": "string"
}{- "id": "room_6812a3b4c5d6e7.12345678",
- "shareCode": "kx9a2bc",
- "rtcRoomId": "string",
- "zegoRoomID": "string",
- "title": "string",
- "imageUrl": "string",
- "listenerCount": 0,
- "speakerCount": 0,
- "totalUserCount": 15,
- "topUserAvatarUrls": [
- "string"
], - "topUsers": [
- {
- "avatarUrl": "string",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}
}
], - "isVideoEnabled": true,
- "seatCount": 0,
- "color": "#DC11FF",
- "isFeatured": true,
- "type": "group",
- "category": "string",
- "wheel": {
- "wheelId": 0,
- "code": "main_wheel_v1"
}, - "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0,
- "seats": [
- {
- "seatIndex": 0,
- "userId": "string",
- "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
- "rtcUserId": "string",
- "zegoUserId": "string",
- "expiresAtMs": 0
}
], - "myMembership": {
- "role": "listener",
- "seatIndex": 0,
- "mayPublish": true,
- "seatExpiresAtMs": 0,
- "forcedListenerOnRejoin": true
}, - "expiresAt": "2019-08-24T14:15:22Z",
- "coinCharged": 0,
- "usedAllowance": true
}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).
{- "serverTimeMs": 0,
- "seatDurationSeconds": 0,
- "freeDailyLimit": 0,
- "seatTakeCost": 0,
- "extendSeconds": 0,
- "extendCoinCost": 0,
- "vipCreateCoinCost": 0,
- "presenceGraceSeconds": 0,
- "heartbeatTimeoutSeconds": 0,
- "vipHostGraceSeconds": 0
}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.
| roomID required | string Examples:
Canonical |
{- "id": "room_6812a3b4c5d6e7.12345678",
- "shareCode": "kx9a2bc",
- "rtcRoomId": "string",
- "zegoRoomID": "string",
- "title": "string",
- "imageUrl": "string",
- "listenerCount": 0,
- "speakerCount": 0,
- "totalUserCount": 15,
- "topUserAvatarUrls": [
- "string"
], - "topUsers": [
- {
- "avatarUrl": "string",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}
}
], - "isVideoEnabled": true,
- "seatCount": 0,
- "color": "#DC11FF",
- "isFeatured": true,
- "type": "group",
- "category": "string",
- "wheel": {
- "wheelId": 0,
- "code": "main_wheel_v1"
}, - "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0,
- "seats": [
- {
- "seatIndex": 0,
- "userId": "string",
- "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
- "rtcUserId": "string",
- "zegoUserId": "string",
- "expiresAtMs": 0
}
], - "myMembership": {
- "role": "listener",
- "seatIndex": 0,
- "mayPublish": true,
- "seatExpiresAtMs": 0,
- "forcedListenerOnRejoin": true
}
}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.
| roomID required | string |
| userName | string |
| avatarUrl | string |
{- "userName": "string",
- "avatarUrl": "string"
}{- "listenerCount": 0,
- "totalUserCount": 15,
- "alreadyMember": true,
- "isHost": true,
- "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0,
- "myMembership": {
- "role": "listener",
- "seatIndex": 0,
- "mayPublish": true,
- "seatExpiresAtMs": 0,
- "forcedListenerOnRejoin": true
}
}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.
| roomID required | string |
| isSpeaker | boolean Default: false Indicates that the member is on a speaker seat (per your product
definition). Sending |
{- "isSpeaker": false
}{- "ok": true,
- "myMembership": {
- "role": "listener",
- "seatIndex": 0,
- "mayPublish": true,
- "seatExpiresAtMs": 0,
- "forcedListenerOnRejoin": true
}
}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.
| roomID required | string |
| target_user_id required | string User key of the heart recipient. |
{- "target_user_id": "string"
}{- "success": true,
- "new_heart_count": 0,
- "targetUserName": "string",
- "targetCallerId": "string",
- "targetUuid": "033dde80-2475-46dc-8ff1-f20cdde41d0a",
- "targetZegoUserId": "string"
}Seat economy (all thresholds configurable via .env; defaults shown):
VOICE_ROOM_FREE_DAILY_LIMIT (default 5) free sits per UTC day,
tracked in voice_room_daily_sits.free_used.extra_sits balance.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.
| roomID required | string |
| 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. |
{- "seatIndex": 0,
- "idempotencyKey": "string"
}{- "expiresAt": 0,
- "serverTimeMs": 0,
- "seatDurationSeconds": 0,
- "heartCount": 0,
- "coinCharged": 0,
- "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0,
- "myMembership": {
- "role": "listener",
- "seatIndex": 0,
- "mayPublish": true,
- "seatExpiresAtMs": 0,
- "forcedListenerOnRejoin": true
}
}| roomID required | string |
| seatIndex required | integer >= 0 |
{- "seatIndex": 0
}{- "message": "Koltuk bırakıldı."
}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.
| roomID required | string |
| 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. |
{- "seatIndex": 0,
- "idempotencyKey": "string"
}{- "newExpiresAt": 0,
- "coinCharged": 0,
- "addedSeconds": 0,
- "usedAllowance": true
}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.
| roomID required | string |
| targetUserID required | string |
| seatIndex required | integer >= 0 |
| idempotencyKey | string Optional client-supplied UUID. See ExtendSeatBody. |
{- "targetUserID": "string",
- "seatIndex": 0,
- "idempotencyKey": "string"
}{- "newExpiresAt": 0,
- "coinCharged": 0,
- "addedSeconds": 0,
- "usedAllowance": true
}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).
| roomID required | string |
{- "code": "string",
- "sectionCount": 0,
- "betMode": "none",
- "roundDurationMs": 0,
- "betOptions": [
- 0
], - "sections": [
- {
- "sectionIndex": 0,
- "rewardKind": "coin_multiplier",
- "multiplier": 1,
- "itemRef": "string",
- "itemQty": 1
}
], - "toggleable": true,
- "maxDistinctSectionsPerUserPerRound": 1,
- "betLockMs": 30000
}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.
| roomID required | string |
{- "round": {
- "roundId": 0,
- "state": "betting",
- "startedAtMs": 0,
- "endsAtMs": 0,
- "sectionTotals": [
- {
- "sectionIndex": 0,
- "totalBet": 0,
- "betCount": 0
}
], - "mine": [
- {
- "betId": 0,
- "roundId": 0,
- "sectionIndex": 0,
- "amount": 0,
- "placedAtMs": 0,
- "payoutStatus": "pending",
- "payoutKind": "none",
- "payoutAmount": 0,
- "payoutItemRef": "string",
- "payoutItemQty": 0
}
]
}, - "nextStartAtMs": 0
}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.
| roomID required | string |
| 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
|
| 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
|
{- "roundId": 0,
- "sectionIndex": 0,
- "amount": 1,
- "idempotencyKey": "string"
}{- "bet": {
- "betId": 0,
- "roundId": 0,
- "sectionIndex": 0,
- "amount": 0,
- "placedAtMs": 0
}
}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).
| roomID required | string |
{- "reset": {
- "roundId": 0,
- "betsRefunded": 0,
- "coinsRefunded": 0
}
}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.
| roomID required | string |
| roundID required | integer |
{- "round": {
- "roundId": 0,
- "state": "pending",
- "startedAtMs": 0,
- "endsAtMs": 0,
- "resolvedAtMs": 0,
- "winningSectionIndex": 0,
- "sectionTotals": [
- {
- "sectionIndex": 0,
- "totalBet": 0,
- "betCount": 0
}
], - "weightsBp": {
- "property1": 0,
- "property2": 0
}
}
}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:
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.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).| roomID required | string |
| wheelCode required | string Wheel definition code (e.g. "classic_8"). Must exist and be
active in the |
{- "wheelCode": "string"
}{- "wheel": {
- "wheelId": 0,
- "code": "string"
}
}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.
| roomID required | string |
{- "ok": true
}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.
| period | string Default: "daily" Enum: "daily" "weekly" "monthly" Rolling time window. Defaults to |
| limit | integer [ 1 .. 100 ] Default: 50 Max entries returned. Clamped to |
{- "period": "daily",
- "sinceMs": 0,
- "untilMs": 0,
- "resetAtMs": 0,
- "leaderboard": [
- {
- "userId": "string",
- "userName": "string",
- "avatarUrl": "string",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}, - "profit": 0,
- "totalStake": 0,
- "totalPayout": 0,
- "winningBetCount": 0
}
]
}Resolves the active daily wheel via app_config.daily_wheel_code
and returns:
sectionCount, dailyLimit.sections[]) — sectionIndex + reward
descriptor for every section, so the client can render the wheel
without a second round-trip.remainingToday spins for the current server
date (Y-m-d).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).
{- "wheelId": 0,
- "wheelCode": "string",
- "sectionCount": 0,
- "dailyLimit": 10,
- "remainingToday": 0,
- "extraSpinsAvailable": 0,
- "sections": [
- {
- "sectionIndex": 0,
- "reward": {
- "kind": "coin",
- "amount": 0,
- "itemRef": "string",
- "itemQty": 0,
- "itemDisplayNameTr": "string"
}
}
], - "resetAt": "17:04:2026 00:00:00:000",
- "serverTime": "16:04:2026 14:23:12:456"
}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.
| 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 |
{- "idempotencyKey": "string"
}{- "roundId": 0,
- "winningSectionIndex": 0,
- "rewardKind": "coin",
- "amount": 0,
- "itemRef": "string",
- "itemQty": 0,
- "itemDisplayNameTr": "string",
- "remainingToday": 0,
- "extraSpinsAvailable": 0,
- "replayed": true
}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).
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 Page size (clamped to [1, 200]). |
{- "rounds": [
- {
- "roundId": 0,
- "state": "pending",
- "startedAtMs": 0,
- "resolvedAtMs": 0,
- "winningSectionIndex": 0,
- "reward": {
- "kind": "coin",
- "amount": 0,
- "itemRef": "string",
- "itemQty": 0,
- "itemDisplayNameTr": "string"
}
}
]
}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.
{- "resetAt": "17:04:2026 00:00:00:000",
- "serverTime": "16:04:2026 14:23:12:456"
}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.
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.
| gameType required | string^[a-z0-9_-]{1,32}$ Example: ludo Game type key (catalog |
| 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 |
| 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 |
{- "idempotencyKey": "a3f1c0b2-9d2e-4f1a-b3c4-5e6f7a8b9c0d",
- "app_page": "ludo_start"
}{- "gameType": "ludo",
- "entryCost": 100,
- "ledgerId": 12345,
- "balanceAfter": 400,
- "referenceId": "game_entry:ludo:a3f1c0b2-...",
- "replayed": true
}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.
| gameType required | string^[a-z0-9_-]{1,32}$ Example: okey Game type key (catalog |
| roundId required | string^[A-Za-z0-9_.:\-]{1,128}$ Example: sud-r-abc123 SUD game_round_id, as delivered to the client in |
{- "status": "pending",
- "didWin": true,
- "amountWon": 180,
- "entryCost": 100,
- "score": 0,
- "balance": 280,
- "settledAtMs": 0
}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).
| gameType required | string^[a-z0-9_-]{1,32}$ Example: okey Game type key (catalog |
| 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. |
{- "targetRoomId": "okey-7f3a9c2b1e4d5a6f"
}{- "roomId": "okey-7f3a9c2b1e4d5a6f",
- "capacity": 4,
- "currentMembers": 2,
- "reused": true
}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.
| gameType required | string^[a-z0-9_-]{1,32}$ Example: okey Game type key (catalog |
| roomId required | string <= 64 characters The room the caller is leaving — the same |
{- "roomId": "okey-7f3a9c2b1e4d5a6f"
}{- "roomId": "okey-7f3a9c2b1e4d5a6f",
- "closed": true,
- "currentMembers": 1
}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.
"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.
| gameType required | string^[a-z0-9_-]{1,32}$ Catalog key ( |
| inviteeCallerId required | string The friend being invited, by |
{- "gameType": "okey",
- "inviteeCallerId": "905322824782"
}{- "invitationId": 1042,
- "expiresAtMs": 1769500800000,
- "status": "pending"
}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.
{- "incoming": [
- {
- "invitationId": 1042,
- "gameType": "okey",
- "gameDisplayName": "101 Okey",
- "roomId": "room_2_lxy",
- "origin": "gameserver",
- "status": "pending",
- "createdAtMs": 1769499900000,
- "expiresAtMs": 1769500800000,
- "inviterCallerId": "905322824781",
- "inviterDisplayName": "Ali",
- "inviteeCallerId": "905322824782",
- "inviteeDisplayName": "Veli"
}
], - "outgoing": [
- {
- "invitationId": 1042,
- "gameType": "okey",
- "gameDisplayName": "101 Okey",
- "roomId": "room_2_lxy",
- "origin": "gameserver",
- "status": "pending",
- "createdAtMs": 1769499900000,
- "expiresAtMs": 1769500800000,
- "inviterCallerId": "905322824781",
- "inviterDisplayName": "Ali",
- "inviteeCallerId": "905322824782",
- "inviteeDisplayName": "Veli"
}
]
}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.
| id required | integer <int64> Example: 1042
|
{- "gameType": "okey",
- "roomId": "room_2_lxy",
- "entryCost": 500
}Invitee only. Terminal — but it frees the inviter to send a fresh invitation for the same game.
| id required | integer <int64> Example: 1042
|
{- "error": {
- "code": "ROOM_NOT_FOUND",
- "message": "Oda bulunamadı.",
- "details": { }
}
}Inviter only. Mirror of decline.
| id required | integer <int64> Example: 1042
|
{- "error": {
- "code": "ROOM_NOT_FOUND",
- "message": "Oda bulunamadı.",
- "details": { }
}
}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.
| id required | integer <int64> Example: 1042
|
| reason | string Enum: "room_full" "game_started" "room_closed" "unknown" The gameserver rejection code. Anything else is stored as |
{- "reason": "room_full"
}{- "error": {
- "code": "ROOM_NOT_FOUND",
- "message": "Oda bulunamadı.",
- "details": { }
}
}Purchases, RevenueCat webhook, package catalog, IVR-recovery, and
server-initiated coin spends. Coin economy SSOT lives in
.claude/plans/wallet-system.md.
Returns rows from user_purchases for the supplied userId. Used by
the mobile client's purchase-history screen.
| userId required | integer |
{- "userId": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| salesChannel | string |
| phoneNumber | string |
| productCode | string |
| transactionDate | string |
| purchaseID | string |
| ipAdress | string |
| durationMinutes | integer |
| status | string |
| property name* additional property | any |
{- "salesChannel": "string",
- "phoneNumber": "string",
- "productCode": "string",
- "transactionDate": "string",
- "purchaseID": "string",
- "ipAdress": "string",
- "durationMinutes": 0,
- "status": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Public endpoint called by RevenueCat. Payload shape per RC docs.
Behavior:
event.id against
revenuecat_webhook_events (idempotent on retries).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.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.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.$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).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| year | string Example: year=2026 4-digit year (defaults to current). |
| month | string Example: month=5 1- or 2-digit month (defaults to current). |
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.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.)
| 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 |
{- "status": "OK",
- "data": {
- "transactions": [
- {
- "id": 90412,
- "type": "spend",
- "amount": -250,
- "balance_after": 4820,
- "reference_id": "gift:8842",
- "action_type": null,
- "created_at": "2026-07-27 11:58:03"
}, - {
- "id": 90388,
- "type": "purchase",
- "amount": 5000,
- "balance_after": 5070,
- "reference_id": "rc:1785240000_tr_coins_5000",
- "action_type": null,
- "created_at": "2026-07-26 20:14:41"
}
], - "pagination": {
- "current_page": 1,
- "per_page": 20,
- "total": 2,
- "total_pages": 1
}
}
}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.
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.
| recipientCallerId required | string Recipient's |
| amount required | integer >= 1 Gross coins the sender pays. Must sit within
|
| 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 |
{- "recipientCallerId": "905551112233",
- "amount": 500,
- "idempotencyKey": "9f1c0f0e-2f7a-4a1e-9a1d-2c4a1b6f0e21",
- "note": "iyi oyundu"
}{- "transferId": 1042,
- "recipientCallerId": "905551112233",
- "recipientDisplayName": "Ayşe",
- "amount": 500,
- "feeCoins": 25,
- "netAmount": 475,
- "senderBalanceAfter": 12500,
- "caps": {
- "sentRemaining24h": 99500,
- "recipientsRemaining24h": 9
}, - "replayed": false,
- "createdAtMs": 1754300000000
}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.
{- "enabled": true,
- "fee": {
- "percent": 5,
- "minCoins": 1,
- "maxCoins": null
}, - "limits": {
- "minAmount": 10,
- "maxAmount": 50000,
- "noteMaxLength": 140
}, - "caps": {
- "sentRemaining24h": 99500,
- "recipientsRemaining24h": 9
}
}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.
| amount required | integer >= 1 Example: amount=500 Gross coins the sender would pay. |
{- "amount": 500,
- "feeCoins": 25,
- "netAmount": 475,
- "caps": {
- "sentRemaining24h": 99500,
- "recipientsRemaining24h": 9
}
}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.
| direction | string Default: "all" Enum: "sent" "received" "all" |
| limit | integer [ 1 .. 50 ] Default: 20 |
| cursor | integer
|
{- "transfers": [
- {
- "transferId": 1042,
- "direction": "sent",
- "amount": 500,
- "feeCoins": 25,
- "netAmount": 475,
- "peerCallerId": "905551112233",
- "peerDisplayName": "Ayşe",
- "note": "iyi oyundu",
- "createdAtMs": 1754300000000
}
], - "hasMore": true,
- "cursor": 1042
}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.
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.
| phone required | string Phone number; non-digits are stripped server-side. |
{- "phone": "string"
}{ }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.
| userId required | string Caller-id-format phone (digits only; non-digits
stripped server-side). Field name is legacy — accepts
|
{- "userId": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| phone | string Phone number. |
| callerid | string |
{- "phone": "string",
- "callerid": "string"
}{- "vip": true,
- "vipStatus": "active",
- "source": "live",
- "stale": true
}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.
| callerid | string Phone number. |
| telefon | string |
| phone | string |
{- "callerid": "string",
- "telefon": "string",
- "phone": "string"
}{ }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.{- "data": {
- "isVip": true,
- "status": "active",
- "source": "live",
- "stale": true,
- "tier": "vip",
- "productId": "string",
- "startedAtMs": 0,
- "expiresAtMs": 0
}
}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.
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):
subscriptionsAdd with productCode=206,
durationMinutes=30 — the canonical VIP write path,
shared with the RevenueCat webhook.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".
{- "data": {
- "trialActivated": true,
- "alreadyActivated": true,
- "alreadyVip": true,
- "skippedTrial": true,
- "productId": "ftu_trial_30m",
- "expiresAtMs": 0,
- "durationMinutes": 0,
- "coinGrant": {
- "status": "granted",
- "granted": true,
- "amount": 10000,
- "balanceAfter": 0,
- "grantedAtMs": 0
}
}
}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"
}
}
| paywallId required | string Enum: "ftu_package1" "ftu_package2" RC product id of the paywall that was dismissed.
|
{- "paywallId": "ftu_package1"
}{- "data": {
- "inboxed": true,
- "reason": "ALREADY_PURCHASED",
- "expiresAtMs": 0
}
}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.
{- "data": {
- "status": "eligible",
- "amount": 10000,
- "grantedAtMs": 0
}
}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
shouldPresentPaywall == true → call presentFtuPaywallSequence().POST /ftu/paywall/dismissed.trialStatus / *Ms fields for logs/analytics only.{- "data": {
- "shouldPresentPaywall": true,
- "trialStatus": "expired",
- "trialExpiresAtMs": 1749550000000,
- "windowExpiresAtMs": 1749636400000
}
}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.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.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.| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
| upToDay required | integer >= 1 Upper bound (inclusive) of the rung to claim. The server clamps to
|
{- "upToDay": 4
}{- "coinAdded": 60,
- "vipAdded": 0,
- "claimedDays": [
- 1,
- 2,
- 3
], - "cycleId": "1747570800-3f9a2c14",
- "dayIndex": 3,
- "newCoinBalance": 5420,
- "newVipMinutes": 0
}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 cyclefalse = unlocked-but-unclaimed (day <= unlockedRung)null = locked (day > unlockedRung, not yet earned by attendance)| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
{- "cycleId": "1747570800-3f9a2c14",
- "cycleLength": 7,
- "anchorDate": "2026-04-09",
- "cycleStartDate": "2026-05-14",
- "attendanceCount": 5,
- "unlockedRung": 5,
- "dayIndex": 5,
- "uninterruptedStreak": 3,
- "rewards": [
- {
- "day": 1,
- "type": "coin",
- "amount": 10,
- "claimed": true
}, - {
- "day": 2,
- "type": "coin",
- "amount": 20,
- "claimed": true
}, - {
- "day": 3,
- "type": "coin",
- "amount": 30,
- "claimed": false
}, - {
- "day": 4,
- "type": "coin",
- "amount": 40,
- "claimed": false
}, - {
- "day": 5,
- "type": "coin",
- "amount": 80,
- "claimed": false
}, - {
- "day": 6,
- "type": "coin",
- "amount": 120,
- "claimed": null
}, - {
- "day": 7,
- "type": "coin",
- "amount": 250,
- "claimed": null
}
]
}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.
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:
category is null) — always visible when active,
in-schedule and audience-passing. Pays its own rewardPayload via
POST /quests/{id}/claim.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):
daily → YYYY-MM-DDweekly → YYYY-Www (ISO week)monthly → YYYY-MM| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
| locale | string Enum: "tr" "en" Locale for |
{- "quests": [
- {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objectiveType": "coins_spent",
- "threshold": 100,
- "progress": 60,
- "rewardType": "coins",
- "rewardPayload": {
- "amount": 50
}, - "title": "Günlük Harcama",
- "description": "Bugün 100 coin harca, 50 coin kazan.",
- "imageUrl": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "isFeatured": false,
- "isPinned": false,
- "status": "inProgress",
- "periodKey": "2026-06-22",
- "category": "oyun",
- "slotIndex": 0
}
], - "homepageQuests": [
- {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objectiveType": "coins_spent",
- "threshold": 100,
- "progress": 60,
- "rewardType": "coins",
- "rewardPayload": {
- "amount": 50
}, - "title": "Günlük Harcama",
- "description": "Bugün 100 coin harca, 50 coin kazan.",
- "imageUrl": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "isFeatured": false,
- "isPinned": false,
- "status": "inProgress",
- "periodKey": "2026-06-22",
- "category": "oyun",
- "slotIndex": 0
}
], - "pinnedQuest": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objectiveType": "coins_spent",
- "threshold": 100,
- "progress": 60,
- "rewardType": "coins",
- "rewardPayload": {
- "amount": 50
}, - "title": "Günlük Harcama",
- "description": "Bugün 100 coin harca, 50 coin kazan.",
- "imageUrl": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "isFeatured": false,
- "isPinned": false,
- "status": "inProgress",
- "periodKey": "2026-06-22",
- "category": "oyun",
- "slotIndex": 0
}, - "stats": {
- "completed": 3,
- "total": 7,
- "weeklyGold": 4200
}, - "ladder": {
- "completions": 2,
- "rungs": [
- {
- "index": 1,
- "amount": 50,
- "status": "locked"
}
]
}, - "weekly": {
- "daysCompleted": 2,
- "requiredDays": 3,
- "amount": 500,
- "status": "locked"
}
}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.
| rung required | integer >= 1 1-based rung index. |
| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
{- "rungIndex": 1,
- "amount": 50,
- "tier": "free",
- "periodKey": "2026-07-21",
- "newCoinBalance": 12450
}Replaces the quest in slot with a different active, feasible
quest from the same category that is not already in the set.
Rules:
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.| slot required | integer >= 0 0-based slot index within today's set. |
| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
{- "slotIndex": 2,
- "questId": 41,
- "category": "oyun",
- "remaining": 0
}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.
| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
{- "amount": 500,
- "periodKey": "2026-W33",
- "newCoinBalance": 12950
}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).
| id required | integer >= 1 Quest definition ID. |
| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
{- "questId": 1,
- "rewardType": "coins",
- "rewardPayload": {
- "amount": 50
}, - "newCoinBalance": 550,
- "periodKey": "2026-06-22"
}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.
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.
| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
| board | string Default: "composite" Enum: "composite" "supporter" "gifter" "community" "game" Which board. Defaults to |
| 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 |
{- "weekId": "2026-W30",
- "board": "composite",
- "status": "open",
- "startsAtMs": 0,
- "endsAtMs": 0,
- "poolTotal": 0,
- "isEstimated": true,
- "totalRanked": 0,
- "top": [
- {
- "rank": 1,
- "callerId": "string",
- "displayName": "string",
- "avatarUrl": "string",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}, - "rawPoints": 0.1
}
], - "me": {
- "rank": 1,
- "rawPoints": 0.1,
- "nextRankGap": 0.1,
- "neighbourhood": [
- {
- "rank": 1,
- "callerId": "string",
- "displayName": "string",
- "avatarUrl": "string",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}, - "rawPoints": 0.1
}
]
}
}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.
| callerid required | string^\d{8,15}$ |
| week | string^\d{4}-W\d{2}$ |
{- "weekId": "2026-W30",
- "hidden": true,
- "boards": {
- "property1": {
- "rawPoints": 0.1,
- "rank": 0,
- "components": {
- "spendPoints": 0.1,
- "giftPoints": 0.1,
- "activeDays": 0,
- "activeDayPoints": 0,
- "distinctKinds": 0,
- "crossUsePoints": 0,
- "firstSpendPoints": 0
}
}, - "property2": {
- "rawPoints": 0.1,
- "rank": 0,
- "components": {
- "spendPoints": 0.1,
- "giftPoints": 0.1,
- "activeDays": 0,
- "activeDayPoints": 0,
- "distinctKinds": 0,
- "crossUsePoints": 0,
- "firstSpendPoints": 0
}
}
}
}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).
| callerid required | string^\d{8,15}$ |
| week | string^\d{4}-W\d{2}$ |
{- "weekId": "2026-W30",
- "ruleVersion": 0,
- "pointUnitCoin": 0,
- "actionMultipliers": {
- "property1": 0.1,
- "property2": 0.1
}, - "compositeWeights": {
- "property1": 0.1,
- "property2": 0.1
}, - "game": { }
}| callerid required | string^\d{8,15}$ |
| limit | integer [ 1 .. 100 ] Default: 20 |
{- "items": [
- {
- "weekId": "string",
- "board": "string",
- "rank": 0,
- "rawPoints": 0.1,
- "weekStatus": "open",
- "rewardId": 0,
- "amount": 0,
- "rewardStatus": "claimable",
- "expiresAtMs": 0
}
]
}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.
| id required | integer <int64>
|
| callerid required | string^\d{8,15}$ |
{- "rewardId": 0,
- "amount": 0,
- "replayed": true
}Includes claimable, claimed and lapsed. Surface expiresAtMs on
claimable rows — the reward is lost if it is not claimed in time.
| callerid required | string^\d{8,15}$ |
{- "items": [
- {
- "rewardId": 0,
- "weekId": "string",
- "board": "string",
- "rank": 0,
- "amount": 0,
- "status": "claimable",
- "expiresAtMs": 0,
- "claimedAtMs": 0
}
]
}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.
{- "items": [
- {
- "itemKey": "free_gift",
- "displayName": "Ücretsiz hediye hakkı",
- "balance": 1,
- "maxCount": 1,
- "expiryMode": "none",
- "expiresAt": "2026-04-22 00:00:00",
- "tier": "normal",
- "unlimited": true
}
], - "resetAt": "22:04:2026 00:00:00:000",
- "serverTime": "21:04:2026 18:42:17:013"
}Identical semantics to /api/wheel/daily/reset-time. The
boundary is system_config.daily_reset_hour_utc (default 0,
i.e. UTC midnight).
{- "resetAt": "22:04:2026 00:00:00:000",
- "serverTime": "21:04:2026 18:42:17:013"
}| itemKey required | string Catalog item key (e.g. |
{- "itemKey": "free_gift",
- "displayName": "Ücretsiz hediye hakkı",
- "balance": 1,
- "maxCount": 1,
- "expiryMode": "none",
- "expiresAt": "2026-04-22 00:00:00",
- "tier": "normal",
- "unlimited": true
}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.
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.
| limit | integer [ 1 .. 500 ] Example: limit=10 When omitted, returns the entire active+listed catalog (no cap).
When present, limits results and enables |
| offset | integer >= 0 Default: 0 Example: offset=0 |
| locale | string Enum: "tr" "en" Example: locale=tr Locale for |
{- "gifts": [
- {
- "id": 7,
- "internalName": "rose",
- "displayName": "Gül",
- "displayNames": {
- "tr": "Gül",
- "en": "Rose"
}, - "imageUrl": "/uploads/gifts/rose.webp",
- "animationUrl": "/uploads/gifts/animations/rose.svga",
- "price": 250
}, - {
- "id": 12,
- "internalName": "crown",
- "displayName": "Taç",
- "displayNames": {
- "tr": "Taç",
- "en": "Crown"
}, - "imageUrl": "/uploads/gifts/crown.webp",
- "animationUrl": null,
- "price": 1500
}
]
}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.
| locale | string Enum: "tr" "en" Example: locale=tr Locale for displayName resolution. Wins over Accept-Language. Fallback: en. |
{- "gifts": [
- {
- "giftId": 7,
- "internalName": "rose",
- "displayName": "Gül",
- "displayNames": {
- "tr": "Gül",
- "en": "Rose"
}, - "imageUrl": "/uploads/gifts/rose.webp",
- "count": 3
}
]
}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.
| userId required | integer Example: 42
|
| locale | string Enum: "tr" "en" Example: locale=tr Locale for displayName resolution. Wins over Accept-Language. Fallback: en. |
{- "gifts": [
- {
- "giftId": 12,
- "internalName": "crown",
- "displayName": "Taç",
- "displayNames": {
- "tr": "Taç",
- "en": "Crown"
}, - "imageUrl": "/uploads/gifts/crown.webp",
- "count": 2
}, - {
- "giftId": 7,
- "internalName": "rose",
- "displayName": "Gül",
- "displayNames": {
- "tr": "Gül",
- "en": "Rose"
}, - "imageUrl": "/uploads/gifts/rose.webp",
- "count": 47
}
]
}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 = false → 503 GIFTS_DISABLED.
| recipientUserId required | integer Target user |
| giftId required | integer
|
| 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 |
object Optional delivery context for real-time push. When supplied,
the server fires a best-effort WS event to the context
( |
{- "recipientUserId": 42,
- "giftId": 7,
- "quantity": 2,
- "idempotencyKey": "e1b9c4a7-2f83-4d16-9a05-7c3e8b2d6f41"
}{- "transferId": 1002,
- "giftId": 7,
- "quantity": 3,
- "coinsSpent": 500,
- "fromInventory": 1,
- "recipientUserId": 42,
- "newInventoryQty": 0,
- "replayed": true
}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 = false → 503 GIFTS_DISABLED.
| recipientUserId required | integer Target user |
required | Array of objects (GiftBatchItem) [ 1 .. 50 ] items The gifts to send. 1–50 distinct gifts; duplicate |
| idempotencyKey required | string Client-supplied key (UUID recommended). Namespaced on the server as
|
object Optional delivery context for real-time push. When supplied, the
server fires a best-effort single |
{- "recipientUserId": 42,
- "items": [
- {
- "giftId": 7,
- "quantity": 3
}
], - "idempotencyKey": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
- "context": {
- "type": "voice_room",
- "id": "101"
}
}{- "batchKey": "gift_send_batch:1:f47ac10b",
- "recipientUserId": 42,
- "items": [
- {
- "transferId": 1001,
- "giftId": 7,
- "quantity": 3,
- "coinsSpent": 20,
- "fromInventory": 1,
- "newInventoryQty": 0
}
], - "totalCoinsSpent": 70,
- "totalQuantity": 4,
- "replayed": false
}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 = false → 503 GIFTS_DISABLED.
| giftId required | integer The single gift type ( |
| 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 |
| idempotencyKey required | string Client-supplied key (UUID recommended). Namespaced on the server as
|
required | object The room to gift into. |
{- "giftId": 7,
- "quantityPerRecipient": 2,
- "recipientCallerIds": [
- "905551112233"
], - "idempotencyKey": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
- "context": {
- "type": "voice_room",
- "id": "room-42"
}
}{- "roomKey": "gift_send_room:1:f47ac10b",
- "giftId": 7,
- "quantityPerRecipient": 2,
- "recipientCount": 3,
- "recipients": [
- {
- "userId": "905551112233",
- "transferId": 1001
}
], - "totalCoinsSpent": 60,
- "totalQuantity": 6,
- "replayed": false
}| targetUserId required | string User key of the target user. |
| message | string Optional message to include with the request. |
{- "targetUserId": "905329876543",
- "message": "Merhaba, odada tanışmıştık."
}{- "requestId": "550e8400-e29b-41d4-a716-446655440000",
- "dbId": 4821,
- "targetUserId": "905329876543",
- "status": "pending",
- "createdAt": 1785326400000
}| 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 |
{- "items": [
- {
- "requestId": "550e8400-e29b-41d4-a716-446655440000",
- "fromUser": {
- "userId": "905329876543",
- "displayName": "Mehmet",
- "activeFrame": null
}, - "message": "Merhaba, odada tanışmıştık.",
- "status": "pending",
- "direction": "incoming",
- "createdAt": 1785326400000
}
], - "nextCursor": null,
- "hasMore": false
}| fromUserId required | string |
| 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. |
{- "alias": "Aşkım",
- "attributes": {
- "source": "voice-room"
}
}{- "ok": true,
- "friendshipCreatedAt": 1785326400000
}| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "userId": "905329876543",
- "displayName": "Mehmet",
- "activeFrame": null,
- "friendSince": 1784980800000,
- "attributes": { }
}, - {
- "userId": "905551112233",
- "displayName": "Zeynep",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}, - "friendSince": 1784894400000,
- "attributes": { }
}
], - "nextCursor": null,
- "hasMore": false
}| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "userId": "905550000031",
- "displayName": "Ahmet",
- "activeFrame": null,
- "mutualFriendCount": 3,
- "mutualFriendsPreview": [
- "Mehmet",
- "Ayşe",
- "Can"
], - "distance": 2,
- "accountType": "human",
- "source": "mutual"
}, - {
- "userId": "999012345678",
- "displayName": "Elif",
- "activeFrame": null,
- "mutualFriendCount": 0,
- "mutualFriendsPreview": [ ],
- "distance": null,
- "accountType": "synthetic",
- "source": "discovery"
}
], - "nextCursor": null,
- "hasMore": true
}| targetUserId required | string |
| message | string |
{- "targetUserId": "905329876543",
- "message": "Oyundan tanışıyoruz."
}{- "ok": true,
- "targetUserId": "905329876543",
- "message": "Arkadaş eklendi."
}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).
| userIds required | Array of strings List of user keys to block. |
{- "userIds": [
- "905329876543",
- "905321234567",
- "905000000000"
]
}{- "ok": true,
- "blockedUserIds": [
- "905329876543"
]
}| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "userId": "905550001122",
- "displayName": "Emre",
- "activeFrame": null,
- "createdAt": 1785153600000
}
], - "nextCursor": null,
- "hasMore": false
}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).
| blockedUserId required | string |
{- "error": {
- "code": "INVALID_USER_ID",
- "message": "Geçersiz kullanıcı bilgisi.",
- "details": { }
}
}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.
| userIds required | Array of strings List of user keys to check relation with. |
{- "userIds": [
- "905329876543",
- "905551112233",
- "905550001122",
- "905550003344",
- "905000000000"
]
}{- "relations": [
- {
- "userId": "905329876543",
- "relation": "mutual_friend",
- "canSendMessages": true
}, - {
- "userId": "905551112233",
- "relation": "outgoing_request",
- "canSendMessages": false
}, - {
- "userId": "905550001122",
- "relation": "blocked_by_me",
- "canSendMessages": false
}, - {
- "userId": "905550003344",
- "relation": "blocked_me",
- "canSendMessages": false
}, - {
- "userId": "905000000000",
- "relation": "none",
- "canSendMessages": false
}
]
}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.
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.
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).
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).
| userIds required | Array of strings List of user keys to query. Capped at |
{- "userIds": [
- "905321234567",
- "905329876543",
- "905551112233"
]
}{- "presence": [
- {
- "userId": "905321234567",
- "isOnline": true,
- "lastSeenMs": 1785326391000
}, - {
- "userId": "905329876543",
- "isOnline": false,
- "lastSeenMs": 1785322800000
}
]
}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.
| If-None-Match | string Example: W/"da39a3ee5e6b4b0d3255bfef95601890afd80709.28651666" ETag from a previous response; a match returns 304. |
{- "items": [
- {
- "userId": "905321234567",
- "displayName": "Ayşe",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}, - "lastSeenMs": 1785326391000
}, - {
- "userId": "905329876543",
- "displayName": "Mehmet",
- "activeFrame": null,
- "lastSeenMs": 1785326102000
}
], - "totalOnline": 2,
- "asOfMs": 1785326400000
}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.
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "creatorUserId": "905321234567",
- "targetUserId": "905329876543",
- "alias": "Aşkım",
- "createdAt": 1784980800000,
- "updatedAt": 1785240000000
}, - {
- "creatorUserId": "905321234567",
- "targetUserId": "905551112233",
- "alias": "İş — Mehmet",
- "createdAt": 1784894400000,
- "updatedAt": 1784894400000
}
], - "nextCursor": null,
- "hasMore": false
}| targetUserId required | string |
{- "creatorUserId": "905321234567",
- "targetUserId": "905329876543",
- "alias": "Aşkım",
- "createdAt": 1784980800000,
- "updatedAt": 1785240000000
}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.
| targetUserId required | string |
| alias required | string [ 1 .. 64 ] characters |
{- "alias": "Aşkım"
}{- "creatorUserId": "905321234567",
- "targetUserId": "905329876543",
- "alias": "Aşkım",
- "createdAt": 1784980800000,
- "updatedAt": 1785326400000
}User-facing avatar catalog, ownership, purchase, and active-avatar
selection. Ownership lives in user_avatars; the active avatar is
user_profiles.avatar_id.
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).
| 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 |
{- "items": [
- {
- "id": 7,
- "name": "Kara Kedi",
- "displayName": "Kara Kedi",
- "internalName": "avatar_7",
- "gender": "male",
- "imageUrl": "/uploads/avatars/1709_abc.webp",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "sortOrder": 10,
- "isUsable": true,
- "isListed": true,
- "createdAt": "2026-04-20 12:34:56",
- "isOwned": true,
- "isActive": true
}
], - "total": 42,
- "limit": 30,
- "offset": 0
}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.
| avatarId required | integer >= 1 |
| idempotencyKey required | string non-empty Client-generated idempotency key (typically a UUIDv4). The
server wraps this as
|
{- "idempotencyKey": "string"
}{- "ok": true,
- "avatarId": 7,
- "replayed": true,
- "priced": true,
- "balanceAfter": 0,
- "ledgerId": 0,
- "tier": "common",
- "name": "string",
- "displayName": "string",
- "internalName": "string"
}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).
{- "items": [
- {
- "id": 7,
- "name": "Kara Kedi",
- "displayName": "Kara Kedi",
- "internalName": "avatar_7",
- "gender": "male",
- "imageUrl": "/uploads/avatars/1709_abc.webp",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "sortOrder": 10,
- "isUsable": true,
- "isListed": true,
- "createdAt": "2026-04-20 12:34:56",
- "isOwned": true,
- "isActive": true,
- "source": "purchase",
- "acquiredAt": "2026-04-20 12:34:56"
}
], - "total": 5
}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.
{- "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
}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.
| avatarId required | integer >= 1 |
{- "avatarId": 1
}{- "avatarId": 7,
- "imageUrl": "/uploads/avatars/1709_abc.webp",
- "tier": "common",
- "name": "string",
- "displayName": "string",
- "internalName": "string"
}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.
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.
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.
| type | string Example: type=frame,background,game_background,avatar Comma-separated type filter; omitted = all registered types.
Accepts background-area keys ( |
| label | string Example: label=featured,new Comma-separated shop-label key filter, matched against each item's
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 |
| 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. |
{- "types": {
- "frame": {
- "total": 1,
- "items": [
- {
- "id": 3,
- "type": "frame",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "internalName": "gold_ring_v2",
- "imageUrl": "/uploads/cosmetics/frames/x.png",
- "price": 500,
- "tier": "epic",
- "isDefault": false,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": null,
- "shopItemId": 42,
- "labels": [
- "featured"
]
}
]
}, - "avatar": {
- "total": 1,
- "items": [
- {
- "id": 9,
- "type": "avatar",
- "name": "Hero",
- "displayName": "Hero",
- "internalName": "avatar_9",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/avatars/x.png",
- "price": 0,
- "tier": "common",
- "isDefault": false,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": {
- "gender": "male"
}, - "shopItemId": 51,
- "labels": [ ]
}
]
}, - "background": {
- "total": 0,
- "items": [ ]
}, - "game_background": {
- "total": 1,
- "items": [
- {
- "id": 77,
- "type": "game_background",
- "name": "Neon Arena",
- "displayName": "Neon Arena",
- "internalName": "neon_arena_v1",
- "imageUrl": "/uploads/cosmetics/game_background/x.jpg",
- "animationUrl": null,
- "price": 250,
- "tier": "rare",
- "isDefault": false,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": null,
- "shopItemId": 118,
- "labels": [ ]
}
]
}
}, - "backgroundTypes": [
- {
- "type": "game_background",
- "displayName": "Game Background",
- "displayNames": {
- "tr": "Oyun Arka Planı",
- "en": "Game Background"
}, - "orientation": "landscape",
- "minWidth": null,
- "minHeight": null,
- "maxWidth": null,
- "maxHeight": null,
- "aspectMin": 1.6,
- "aspectMax": 1.9,
- "maxBytes": 20971520,
- "absMaxBytes": 31457280,
- "allowedMimeTypes": [
- "image/jpeg",
- "image/png",
- "image/webp"
], - "animationAllowed": true,
- "animationMaxBytes": 10485760,
- "animationAbsMaxBytes": 31457280,
- "sortOrder": 10
}
], - "labels": [
- {
- "key": "featured",
- "displayName": "Öne Çıkan",
- "displayNames": {
- "tr": "Öne Çıkan",
- "en": "Featured"
}, - "color": "#FFD700",
- "backgroundColor": "#1A1A1A",
- "iconUrl": "/uploads/shop-labels/1_abc.webp",
- "iconEmoji": null,
- "iconType": "image",
- "placement": "badge",
- "kind": "manual",
- "sortOrder": 10
}, - {
- "key": "new",
- "displayName": "Yeni",
- "displayNames": {
- "tr": "Yeni",
- "en": "New"
}, - "color": null,
- "backgroundColor": null,
- "iconUrl": null,
- "iconEmoji": "🆕",
- "iconType": "emoji",
- "placement": "chip",
- "kind": "both",
- "sortOrder": 20
}
], - "limit": 200,
- "offset": 0
}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.
| 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. |
| 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
|
{- "items": [
- {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true
}
], - "total": 42,
- "limit": 200,
- "offset": 0
}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.
| 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 |
| idempotencyKey required | string non-empty Client-generated idempotency key (typically a UUIDv4). The
server wraps it as
|
{- "idempotencyKey": "string"
}{- "ok": true,
- "itemId": 7,
- "type": "game_background",
- "replayed": true,
- "priced": true,
- "price": 0,
- "balance": 0,
- "tier": "common",
- "name": "string",
- "displayName": "string",
- "internalName": "string"
}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.
{- "owned": {
- "frame": [
- {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true,
- "source": "purchase",
- "acquiredAt": "2026-06-12 12:34:56"
}
], - "background": [
- {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true,
- "source": "purchase",
- "acquiredAt": "2026-06-12 12:34:56"
}
], - "avatar": [
- {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true,
- "source": "purchase",
- "acquiredAt": "2026-06-12 12:34:56"
}
]
}, - "active": {
- "frame": {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true,
- "source": "purchase",
- "acquiredAt": "2026-06-12 12:34:56"
}, - "background": {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true,
- "source": "purchase",
- "acquiredAt": "2026-06-12 12:34:56"
}, - "avatar": {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true,
- "source": "purchase",
- "acquiredAt": "2026-06-12 12:34:56"
}
}
}{"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.
| 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. |
| itemId required | integer or null >= 1 Item to equip, or |
{- "itemId": 1
}{- "ok": true,
- "type": "game_background",
- "itemId": 7
}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:
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.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.
The entry point to the dynamic background system. Returns the render/upload config for every active background area.
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.
GET /v1/backgrounds/types (this endpoint) → the area catalog +
per-area render params.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).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).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.
| locale | string Enum: "tr" "en" Overrides Accept-Language for displayName resolution. |
{- "types": [
- {
- "type": "game_background",
- "displayName": "Game Background",
- "displayNames": {
- "tr": "Oyun Arka Planı",
- "en": "Game Background"
}, - "orientation": "landscape",
- "minWidth": 0,
- "minHeight": 0,
- "maxWidth": 0,
- "maxHeight": 0,
- "aspectMin": 0,
- "aspectMax": 0,
- "maxBytes": 0,
- "absMaxBytes": 0,
- "allowedMimeTypes": [
- "image/jpeg",
- "image/png",
- "image/webp"
], - "animationAllowed": true,
- "animationMaxBytes": 0,
- "animationAbsMaxBytes": 0,
- "sortOrder": 0
}
]
}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.
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.
| 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 |
{- "types": [ ],
- "labels": [ ],
- "limit": 200,
- "offset": 0,
- "packages": [ ],
- "gifts": [
- {
- "id": 7,
- "shopItemId": 22,
- "internalName": "rose",
- "displayName": "Gül",
- "displayNames": {
- "tr": "Gül",
- "en": "Rose"
}, - "imageUrl": "/uploads/gifts/rose.webp",
- "price": 50,
- "tier": "common",
- "labels": [
- "featured"
]
}
]
}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.
| Accept-Language | string Example: tr Locale for |
{- "labels": [
- {
- "key": "featured",
- "displayName": "Öne Çıkan",
- "displayNames": {
- "tr": "Öne Çıkan",
- "en": "Featured"
}, - "color": "#FFD700",
- "backgroundColor": "#1A1A1A",
- "iconUrl": "/uploads/shop-labels/1_abc.webp",
- "iconEmoji": null,
- "iconType": "image",
- "placement": "badge",
- "kind": "manual",
- "sortOrder": 10
}
]
}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.
| Accept-Language | string Example: tr Locale for |
{- "tiers": [
- {
- "id": 0,
- "key": "legendary",
- "displayName": "string",
- "displayNames": {
- "property1": "string",
- "property2": "string"
}, - "color": "string",
- "backgroundColor": "string",
- "icon": "string",
- "iconUrl": "string",
- "sortOrder": 0,
- "isActive": true
}
]
}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.
| id required | integer >= 1 |
{- "id": 0,
- "shopItemId": 0,
- "internalName": "string",
- "title": {
- "displayName": "string",
- "displayNames": {
- "property1": "string",
- "property2": "string"
}
}, - "subtext": {
- "displayName": "string",
- "displayNames": {
- "property1": "string",
- "property2": "string"
}
}, - "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": [
- "string"
], - "contents": [
- {
- "shopItemId": 0,
- "kind": "string",
- "displayName": "string",
- "iconUrl": "string",
- "imageUrl": "string",
- "quantity": 0,
- "owned": true
}
]
}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.
| shopItemId required | integer >= 1 |
| clientKey required | string Client idempotency key. |
| acknowledgeOwned | boolean Default: false Retired — accepted and ignored. Owned lines are skipped and discounted automatically. |
{- "clientKey": "string",
- "acknowledgeOwned": false
}{ }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.
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.
| callerid required | string Owner identifier ( |
| 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 ( |
| status | string Default: "pending" Enum: "pending" "approved" "rejected" Initial pipeline status. Defaults to |
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. |
{- "callerid": "905557773000",
- "assetId": "asset-9f3a2b",
- "status": "pending",
- "labels": {
- "nsfw": 0.01,
- "violence": 0
}
}{- "ok": true
}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.
| assetId required | string <= 64 characters Example: asset-9f3a2b The CDN |
| status required | string (ProfileImageStatus) Enum: "pending" "approved" "rejected" Moderation state of a profile photo. The CDN's |
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. |
{- "status": "pending",
- "labels": {
- "nsfw": 0.01,
- "violence": 0
}
}{- "ok": true
}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.
| source required | string Enum: "photo" "avatar" Which kind fills the slot. |
| imageId | integer <int64> Required when |
| avatarId | integer Required when |
{- "source": "photo",
- "imageId": 4210
}{- "pictureSource": "photo",
- "selectedProfileImageId": 4210,
- "avatarId": 17,
- "isUploaded": true
}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.
{- "status": "pending",
- "updatedAtMs": 1717804800000
}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.
{- "items": [
- {
- "imageId": 4210,
- "status": "pending",
- "createdAtMs": 1717804800000,
- "updatedAtMs": 1717804800000
}
]
}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.
{- "horoscopes": [
- {
- "id": 17,
- "code": "leo",
- "icon": "♌",
- "iconUrl": "/uploads/horoscope-icons/1720000000_ab12cd.png",
- "name": "Aslan"
}
]
}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.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| sessionUuid required | string The |
| rating required | integer [ 1 .. 5 ] User-given star rating (1..5). |
| issueCode | string Enum: "audio_cut" "echo" "disconnect" "latency" "no_remote_voice" Required when |
| issueText | string <= 280 characters Optional free-text follow-up. Trimmed and truncated to 280 chars
server-side; rejected if it contains |
| 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. |
{- "rating": 1,
- "issueCode": "audio_cut",
- "issueText": "string",
- "callDurationSec": 0,
- "platform": "ios",
- "appVersion": "string",
- "sentAtMs": 0
}{- "data": {
- "feedbackId": "8a5fb68c-aae2-438d-9a46-b4d8c8788e0b",
- "sessionUuid": "string",
- "rating": 1,
- "issueCode": "audio_cut",
- "createdAtMs": 0,
- "replayed": true
}
}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).(userId, sessionUuid). A duplicate POST returns
HTTP 200 with replayed: true and the original row; first POST
returns HTTP 201.userId = phone) must be one of the two
participants of the session (call_sessions.user_phone1 or
user_phone2); otherwise 403 FORBIDDEN.| sessionUuid required | string Call session UUID (= |
| rating required | integer [ 1 .. 5 ] Star rating from 1 to 5. |
| reasonCode | string or null Enum: "inappropriate" "underage" "other" null Required when |
| reasonText | string or null <= 240 characters Free-text explanation. Accepted only when
|
| 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). |
{- "rating": 1,
- "reasonCode": "inappropriate",
- "reasonText": "string",
- "callDurationSec": 0,
- "ratedAtMs": 0,
- "platform": "ios",
- "appVersion": "string"
}{- "ratingId": "e2685a8c-cb0d-4ffd-85d1-e4f8966e7aa9",
- "sessionUuid": "string",
- "rating": 1,
- "reasonCode": "inappropriate",
- "storedAtMs": 0,
- "replayed": true
}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:
userId = phone) must be one of the two
participants of the session, otherwise 403 FORBIDDEN.alreadyEnded: true and does not overwrite the original
end metadata.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.| sessionUuid required | string The |
| reason | string <= 50 characters ^[a-z0-9_]*$ Default: "user_closed" Client-declared end reason for server logs, e.g. |
{- "reason": "user_closed"
}{- "data": {
- "sessionUuid": "string",
- "endedBy": "string",
- "reason": "string",
- "alreadyEnded": true
}
}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).
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).
| messageId required | string <= 64 characters ZIM message id ( |
| 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
|
| receiverUserId required | string Receiver's |
| 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. |
{- "messageId": "1745890000000123",
- "conversationId": "5511999998888",
- "receiverUserId": "5511999998888",
- "textPreview": "Selam, naber?",
- "createdAtMs": 1745890000000
}{- "delivered": 0,
- "tokens": 0,
- "replayed": true
}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.
{- "peerId": "string",
- "conversationId": "string",
- "started": true,
- "frozen": true,
- "isBot": true,
- "message": {
- "id": 0,
- "senderId": "905551112233",
- "text": "string",
- "createdAtMs": 0,
- "readAtMs": 0,
- "clientMessageId": "string"
}
}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.
| 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. |
{- "conversations": [
- {
- "peerId": "string",
- "displayName": "string",
- "avatarUrl": "string",
- "activeFrame": {
- "id": 12,
- "imageUrl": "/uploads/cosmetics/frames/gold.webp",
- "animationUrl": "/uploads/cosmetics/frames/animations/gold.svga"
}, - "isBot": true,
- "lastMessage": {
- "text": "string",
- "senderId": "string",
- "createdAtMs": 0
}, - "unreadCount": 0,
- "updatedAtMs": 0,
- "isMuted": true,
- "mutedUntilMs": 0
}
], - "hasMore": true,
- "nextCursor": "string"
}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.
| peerId required | string Peer's callerid. |
{- "ok": true
}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).
| peerId required | string Peer's callerid. |
| 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. |
{- "messages": [
- {
- "id": 0,
- "senderId": "905551112233",
- "text": "string",
- "createdAtMs": 0,
- "readAtMs": 0,
- "clientMessageId": "string"
}
], - "hasMore": true,
- "nextCursor": "string"
}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.
| peerId required | string Peer's callerid. |
| 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. |
{- "text": "string",
- "clientMessageId": "string"
}{- "message": {
- "id": 0,
- "senderId": "905551112233",
- "text": "string",
- "createdAtMs": 0,
- "readAtMs": 0,
- "clientMessageId": "string"
}
}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.
| peerId required | string Peer's callerid. |
{- "ok": true
}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.
| peerId required | string Peer's callerid. |
{- "ok": true
}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.
| peerId required | string Peer's callerid. |
| durationMs | integer <int64> [ 0 .. 31536000000 ] Optional. Omitted / null / 0 = indefinite. Otherwise the mute
expires |
{- "durationMs": 3600000
}{- "ok": true,
- "isMuted": true,
- "mutedUntilMs": 0
}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.
| peerId required | string Peer's callerid. |
{- "ok": true,
- "isMuted": true,
- "mutedUntilMs": 0
}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.
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.
| room required | string <= 128 characters LiveKit room name (charset |
| identity | string Optional client-claimed identity; must match the derived one. |
{- "url": "wss://tcfunkit.telpass-ltd.live",
- "token": "string",
- "expiresAtMs": 0,
- "identity": "string"
}Server-initiated notification trigger. Body shape is consumer-specific (caller passes through Firebase notification payload). Returns the underlying delivery result.
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Account-scoped settings: device/FCM token registration, profile bits. All endpoints require JWT auth and operate on the calling user.
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:
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).
| token required | string [ 32 .. 4096 ] characters FCM registration token ( |
| deviceType | string Free-form platform tag — typically |
| 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
|
{- "token": "fA9k_LdEQUe1mQ2xR7vN0T:APA91bH3kZq8YQ7Pr3nV2sD4wX6yL1cM9jF5gK0oB8tR2eU7iA3pN6hS4dW1zX",
- "deviceType": "android",
- "appVersion": "2.14.0",
- "deviceId": "a3f1c9d2e8b4"
}{- "ok": true
}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.
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.
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 Legacy offset paging. Ignored when |
| 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 |
{- "items": [
- {
- "kind": "personal",
- "id": 0,
- "title": "string",
- "pushTitle": "string",
- "body": "string",
- "metadata": { },
- "scheduledAt": "2019-08-24T14:15:22Z",
- "pushEnabled": true,
- "deliveryStatus": "pending",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "readAt": "2019-08-24T14:15:22Z"
}
], - "limit": 1,
- "offset": 0,
- "direction": "asc",
- "hasMore": true,
- "nextCursor": "string"
}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
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.
{- "title": "[SYSTEM]",
- "unreadCount": 0,
- "hasMessages": true,
- "lastMessage": "string",
- "lastAt": "2019-08-24T14:15:22Z",
- "lastId": 0
}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.
| kind required | string Enum: "personal" "announcement" |
| id required | integer <int64> |
{- "deleted": true,
- "read": true,
- "disabled": true
}kind is one of personal or announcement — see markRead.
| kind required | string Enum: "personal" "announcement" |
| id required | integer <int64> |
{- "deleted": true,
- "read": true,
- "disabled": true
}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.
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.
{- "data": [
- {
- "code": "call_quality_broken",
- "label": "Görüşme kalitesi kötü",
- "isFreetext": false
}
]
}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.
| 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). |
{- "rating": 2,
- "reasonCode": "too_many_ads",
- "reasonText": "Uygulama çok sık donuyor",
- "platform": "ios",
- "appVersion": "1.42.0",
- "sentAtMs": 0
}{- "data": {
- "reviewId": "341516c7-7748-4edf-830d-9d7fcae997b7",
- "rating": 2,
- "reasonCode": "too_many_ads",
- "createdAtMs": 0
}
}code must match ^[a-z0-9_]{2,48}$ and be unique.
| code required | string^[a-z0-9_]{2,48}$ |
| labelTr required | string <= 120 characters |
| isFreetext | boolean Default: false |
| sortOrder | integer Default: 0 |
{- "code": "slow_loading",
- "labelTr": "Yavaş açılıyor",
- "isFreetext": false,
- "sortOrder": 0
}{- "status": "OK",
- "data": {
- "id": 0,
- "code": "string",
- "label": "string",
- "isFreetext": true,
- "isActive": true,
- "sortOrder": 0
}
}| id required | integer |
| labelTr | string <= 120 characters |
| isFreetext | boolean |
| isActive | boolean |
| sortOrder | integer |
{- "labelTr": "string",
- "isFreetext": true,
- "isActive": true,
- "sortOrder": 0
}{- "status": "OK",
- "data": {
- "id": 0,
- "code": "string",
- "label": "string",
- "isFreetext": true,
- "isActive": true,
- "sortOrder": 0
}
}Aggregates submissions: total count, average rating, star distribution, low-rating reason breakdown (labelled from the catalog), and the most recent free-text entries.
| from | integer <int64> Lower bound, epoch ms. |
| to | integer <int64> Upper bound, epoch ms. |
| platform | string Enum: "ios" "android" |
{- "status": "OK",
- "data": {
- "range": {
- "fromMs": 0,
- "toMs": 0
}, - "total": 1234,
- "avgRating": 3.87,
- "distribution": {
- "1": 40,
- "2": 60,
- "3": 120,
- "4": 400,
- "5": 614
}, - "reasonBreakdown": [
- {
- "code": "string",
- "label": "string",
- "count": 0
}
], - "recentOther": [
- {
- "reviewId": "341516c7-7748-4edf-830d-9d7fcae997b7",
- "text": "string",
- "createdAtMs": 0
}
]
}
}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.
| X-App-Build required | integer |
| X-App-Platform required | string Enum: "android" "ios" |
{- "data": {
- "updateStatus": "forced",
- "currentBuild": 4,
- "minBuild": 5,
- "latestBuild": 9,
- "platform": "android",
- "storeUrl": "string",
- "title": "string",
- "message": "string"
}
}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.
| versionNo | string |
| appVersion | string Alias. |
| platform | string e.g. android, ios. |
| property name* additional property | any |
{- "versionNo": "string",
- "appVersion": "string",
- "platform": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Public route — no JWT required.
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}DEPRECATED. Superseded by POST /app-review (see docs/systems/app-review.md).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}DEPRECATED. Superseded by the /app-review surface (see docs/systems/app-review.md).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}DEPRECATED. Superseded by the /app-review surface (see docs/systems/app-review.md).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Loyalty points are a separate currency from coins (see
/getCoinBalance). Used by the old reward / streak surface.
| userId | integer |
| property name* additional property | any |
{- "userId": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Endpoint name preserves the original misspelling (Substract) for
backward compatibility with mobile clients.
| userId | integer |
| amount | integer |
| property name* additional property | any |
{- "userId": 0,
- "amount": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Generic WhatsApp service multiplexer — the body's action field
selects the underlying operation. See the WhatsAppService
implementation (phantom; verify before relying on this endpoint).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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').
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
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):
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.user_allowances,
user_allowance_history).reward_claims).wheel_bets, user_daily_wheel_spins,
scope=daily rows in wheel_rounds).voice_room_seat_timers,
voice_room_daily_sits, voice_room_members).room_closed broadcast to listeners).subscriptionsDelete. IVR
failure does NOT abort the rest of the reset; surfaced in
result.vip so the admin can retry manually.user_sessions, user_refresh_tokens, user_devices
cleared so the next login forces a full re-auth flow.users (calls / swipes / hearts / last_login_at).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.
| callerId required | string Target user callerid (digits-only, max 20 chars). |
| dryRun | boolean Default: true When true (default), no writes happen — response contains a
|
| idempotencyKey | string Optional. Defaults to |
{- "dryRun": true,
- "idempotencyKey": "string"
}{- "status": "OK",
- "data": {
- "dryRun": true,
- "preview": {
- "callerId": "string",
- "userId": 0,
- "userKey": "string",
- "walletBalance": 0,
- "rcBalance": 0,
- "rcReadStatus": "string",
- "vipUuid": "string",
- "hostedRoomIds": [
- "string"
], - "bucketCounts": {
- "property1": 0,
- "property2": 0
}, - "dailyStreak": {
- "status": "present",
- "cycleId": "string",
- "dayIndex": 0,
- "error": "string"
}
}
}
}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).
| callerId required | string Target user callerid (digits-only, max 20 chars). |
| accountType required | string Enum: "human" "dev" New account type. |
{- "accountType": "dev"
}{- "status": "OK",
- "data": {
- "callerId": "905550030001",
- "previous": "human",
- "accountType": "dev"
}
}Admin-panel surface — catalog CRUD, tier policies, grant rules, per-user grants, history
| active | string Enum: "true" "false" Example: active=true Filter by |
| q | string Example: q=avatar_slot Partial match on |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "data": {
- "items": [
- {
- "id": 0,
- "itemKey": "free_gift",
- "displayNameTr": "Ücretsiz hediye hakkı",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true,
- "createdAt": "string",
- "updatedAt": "string"
}
], - "total": 0,
- "limit": 1,
- "offset": 0
}
}| 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 |
{- "itemKey": "string",
- "displayNameTr": "string",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemKey": "free_gift",
- "displayNameTr": "Ücretsiz hediye hakkı",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true,
- "createdAt": "string",
- "updatedAt": "string"
}
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemKey": "free_gift",
- "displayNameTr": "Ücretsiz hediye hakkı",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true,
- "createdAt": "string",
- "updatedAt": "string"
}
}| id required | integer >= 1 |
| 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 |
{- "displayNameTr": "string",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemKey": "free_gift",
- "displayNameTr": "Ücretsiz hediye hakkı",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true,
- "createdAt": "string",
- "updatedAt": "string"
}
}Hard delete is not exposed; user_allowance_history references would orphan.
| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "id": 0,
- "isActive": false,
- "softDeleted": true
}
}| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "itemId": 0,
- "tierPolicies": [
- {
- "id": 0,
- "itemId": 0,
- "tier": "normal",
- "isEnforced": true,
- "notes": "string",
- "updatedAt": "string"
}
]
}
}| id required | integer >= 1 |
| tier required | string Enum: "normal" "vip" |
| isEnforced required | boolean |
| notes | string Pass |
{- "isEnforced": true,
- "notes": "string"
}{- "status": "OK",
- "data": {
- "itemId": 0,
- "tierPolicies": [
- {
- "id": 0,
- "itemId": 0,
- "tier": "normal",
- "isEnforced": true,
- "notes": "string",
- "updatedAt": "string"
}
]
}
}| sourceKey required | string^[a-z0-9_]+$ |
| displayName required | string |
| isActive | boolean Default: true |
{- "sourceKey": "string",
- "displayName": "string",
- "isActive": true
}{- "status": "OK",
- "data": {
- "id": 0,
- "sourceKey": "daily_reset",
- "displayName": "string",
- "isActive": true
}
}| id required | integer >= 1 |
| displayName | string |
| isActive | boolean |
{- "displayName": "string",
- "isActive": true
}{- "status": "OK",
- "data": {
- "id": 0,
- "sourceKey": "daily_reset",
- "displayName": "string",
- "isActive": true
}
}| 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 |
{- "status": "OK",
- "data": {
- "items": [
- {
- "id": 0,
- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "refillIntervalSeconds": 1,
- "strategy": "set_to",
- "isActive": true,
- "params": null,
- "createdAt": "string",
- "updatedAt": "string"
}
], - "total": 0,
- "limit": 1,
- "offset": 0
}
}(itemId, grantSourceId, tier) is unique — duplicates return
409 GRANT_RULE_CONFLICT.
| 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 |
{- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "strategy": "set_to",
- "refillIntervalSeconds": 1,
- "isActive": true,
- "params": null
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "refillIntervalSeconds": 1,
- "strategy": "set_to",
- "isActive": true,
- "params": null,
- "createdAt": "string",
- "updatedAt": "string"
}
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "refillIntervalSeconds": 1,
- "strategy": "set_to",
- "isActive": true,
- "params": null,
- "createdAt": "string",
- "updatedAt": "string"
}
}itemId, grantSourceId, tier are immutable — create a new rule
to change them.
| id required | integer >= 1 |
| amount | integer >= 0 |
| strategy | string Enum: "set_to" "add" "set_to_at_least" |
| refillIntervalSeconds | integer or null >= 1 |
| isActive | boolean |
| params | any |
{- "amount": 0,
- "strategy": "set_to",
- "refillIntervalSeconds": 1,
- "isActive": true,
- "params": null
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "refillIntervalSeconds": 1,
- "strategy": "set_to",
- "isActive": true,
- "params": null,
- "createdAt": "string",
- "updatedAt": "string"
}
}| q | string Example: q=5678 Optional substring match on |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "data": {
- "items": [
- {
- "id": 0,
- "callerId": "string",
- "totalOpenAllowances": 0
}
], - "total": 0,
- "limit": 1,
- "offset": 0
}
}| callerId required | string <= 20 characters Phone-format caller id ( |
{- "status": "OK",
- "data": {
- "callerId": "string",
- "balances": [
- {
- "itemKey": "free_gift",
- "displayName": "Ücretsiz hediye hakkı",
- "balance": 1,
- "maxCount": 1,
- "expiryMode": "none",
- "expiresAt": "2026-04-22 00:00:00",
- "tier": "normal",
- "unlimited": true
}
]
}
}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.
| callerId required | string <= 20 characters Phone-format caller id ( |
| 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 |
object |
{- "itemKey": "string",
- "amount": 0,
- "strategy": "set_to",
- "referenceId": "string",
- "metadata": { }
}{- "status": "OK",
- "data": {
- "ok": true,
- "itemKey": "string",
- "granted": 0,
- "strategy": "string",
- "previous": 0,
- "balance": 0,
- "delta": 0,
- "expiresAt": "string",
- "historyId": 0,
- "replayed": true,
- "referenceId": "string"
}
}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.
| callerId required | string <= 20 characters Phone-format caller id ( |
| itemKey required | string |
| amount required | integer >= 1 |
| referenceId | string |
object |
{- "itemKey": "string",
- "amount": 1,
- "referenceId": "string",
- "metadata": { }
}{- "status": "OK",
- "data": {
- "ok": true,
- "itemKey": "string",
- "tier": "normal",
- "consumed": 0,
- "balance": 0,
- "historyId": 0,
- "unlimited": true,
- "replayed": true,
- "referenceId": "string"
}
}| 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 |
| to | string Example: to=2026-05-05 23:59:59 Upper bound on |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "data": {
- "items": [
- {
- "id": 0,
- "userId": 0,
- "callerId": "string",
- "itemId": 0,
- "itemKey": "string",
- "amount": 0,
- "balanceAfter": 0,
- "source": "grant",
- "grantSourceId": 0,
- "grantRuleId": 0,
- "referenceId": "string",
- "metadata": null,
- "createdAt": "string"
}
], - "total": 0,
- "limit": 1,
- "offset": 0
}
}Lists rows from the avatars catalog. All /admin/* routes are
JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key).
| gender | string Example: gender=female Filter by gender ( |
| usable | string Example: usable=true
|
| listed | string Example: listed=true
|
| active | string Example: active=true Deprecated alias for |
| limit | integer Default: 30 Example: limit=30 |
| offset | integer Default: 0 Example: offset=0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| 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_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 |
{- "displayName": "string",
- "name": "string",
- "internalName": "string",
- "gender": "male",
- "price": 0,
- "tier": "common",
- "is_default": true,
- "is_usable": 0,
- "is_listed": 0,
- "sort_order": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| id required | integer |
| 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 |
{- "displayName": "string",
- "name": "string",
- "internalName": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.| id required | integer |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Lists rows from the interests catalog with localized names. All
/admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware
(X-Admin-Key).
| active | string Example: active=true
|
| q | string Substring match against |
| limit | integer Default: 30 Example: limit=30 |
| offset | integer Default: 0 Example: offset=0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| code required | string 1–50 chars, |
| 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. |
| image required | string <binary> Icon image file (png/jpeg/webp, ≤2 MB, ≤512×512 by default). |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| order required | Array of integers |
{- "order": [
- 3,
- 1,
- 2
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Partial update. Supplying a new image replaces the icon and deletes
the previous file. Supplying names upserts those locales.
| id required | integer |
| code | string |
| name | string |
| icon | string |
| isActive | boolean |
| sortOrder | integer |
| names | string JSON map of localized names. |
| image | string <binary> |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| id required | integer |
| force | string Value: "1" Required to delete an interest that users have selected. Without force the request returns 409 INTEREST_IN_USE. |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
{- "activeCalls": 12,
- "callersInCalls": 24,
- "real": 22,
- "dev": 2,
- "synthetic": 0,
- "asOfMs": 1719100000000,
- "stale": false,
- "calls": [
- {
- "sessionUuid": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
- "startedAtMs": 1719099857000,
- "durationSeconds": 143,
- "isInfinite": false,
- "participants": [
- {
- "phone": "905550000001",
- "userName": "aylin",
- "isDev": false
}
]
}
]
}Admin horoscope (zodiac) catalog — icon/name update only; fixed 12-sign catalog (no create/delete/reorder)
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.
| active | string Example: active=true
|
| q | string Substring match against |
| limit | integer Default: 30 Example: limit=30 |
| offset | integer Default: 0 Example: offset=0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| id required | integer |
| code | string |
| name | string |
| icon | string |
| isActive | boolean |
| sortOrder | integer |
| names | string JSON map of localized names, e.g. |
| image | string <binary> Icon image file (png/jpeg/webp/svg, ≤2 MB, ≤512×512 by default). |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Admin cosmetics catalog CRUD per type (frames, backgrounds, banners) — image + SVGA animation upload rules, guarded delete, reorder, label assignment
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.
| type required | string Example: game_background Registry-resolved cosmetic type. NOT a fixed enum: the static types
( |
| q | string Substring match against |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer Default: 0 Example: offset=0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "items": [
- {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true
}
], - "total": 42,
- "limit": 30,
- "offset": 0
}, - "error": "string"
}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.
| type required | string Example: game_background Registry-resolved cosmetic type. NOT a fixed enum: the static types
( |
| 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. |
| 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. |
| 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 |
| image required | string <binary> Item image; constraints per type (see rules endpoint). SOFT
rejections return |
| animation | string <binary> Optional SVGA animation file (v1 zip or v2 zlib container). |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true
}, - "error": "string"
}Registry defaults merged with system_config overrides — the
panel reads these for client-side upload guardrails.
| 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. |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| 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. |
| order required | Array of integers |
{- "order": [
- 3,
- 1,
- 2
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| 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 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| 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 |
| 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. |
| 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. |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| 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 |
| force | string Value: "1" Required to delete an item that is owned or selected. Without force the request returns 409 COSMETIC_IN_USE. |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Moves a background item from one area to another (e.g.
game_background → voiceroom_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).
| type required | string Source area (a background_types key). |
| id required | integer |
| force | string Value: "1" Clear equipped selections and move (required when the item has active selectors). |
| targetType required | string Destination area (a background_types key). |
{- "targetType": "voiceroom_background"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "id": 7,
- "type": "game_background",
- "name": "Gold Ring",
- "displayName": "Gold Ring",
- "displayNames": {
- "tr": "Altın Çerçeve",
- "en": "Gold Ring"
}, - "internalName": "gold_ring_v2",
- "gender": "male",
- "imageUrl": "/uploads/cosmetics/frames/1718_abc.png",
- "animationUrl": "/uploads/cosmetics/frames/animations/1751800000_a1b2c3d4e5f6.svga",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "isUsable": true,
- "isListed": true,
- "sortOrder": 10,
- "attrs": { },
- "createdAt": "2026-06-12 12:34:56",
- "updatedAt": "2026-06-12 12:34:56",
- "shopItemId": 42,
- "labels": [
- "featured",
- "new"
], - "isOwned": true,
- "isActive": true
}, - "error": "string"
}Convenience alias on the admin cosmetics surface. Resolves the
cosmetic_items.id → shop_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).
| 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 |
| add | Array of strings Label keys to add. |
| remove | Array of strings Label keys to remove. |
{- "add": [
- "string"
], - "remove": [
- "string"
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| q | string Substring match against type_key or display name. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| offset | integer Default: 0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "items": [
- {
- "type": "game_background",
- "displayName": "Game Background",
- "displayNames": {
- "tr": "Oyun Arka Planı",
- "en": "Game Background"
}, - "orientation": "landscape",
- "minWidth": 0,
- "minHeight": 0,
- "maxWidth": 0,
- "maxHeight": 0,
- "aspectMin": 0,
- "aspectMax": 0,
- "maxBytes": 0,
- "absMaxBytes": 0,
- "allowedMimeTypes": [
- "image/jpeg",
- "image/png",
- "image/webp"
], - "animationAllowed": true,
- "animationMaxBytes": 0,
- "animationAbsMaxBytes": 0,
- "sortOrder": 0,
- "id": 0,
- "isActive": true,
- "configPrefix": "game_background_image",
- "uploadDirSegment": "game_background",
- "absMaxWidth": 0,
- "absMaxHeight": 0,
- "createdAt": "string"
}
], - "total": 0,
- "limit": 0,
- "offset": 0
}, - "error": "string"
}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.
| 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 |
{- "typeKey": "game_background",
- "displayNames": {
- "tr": "Oyun Arka Planı",
- "en": "Game Background"
}, - "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": {
- "property1": "string",
- "property2": "string"
}, - "animationAllowed": true,
- "animationMaxBytes": 0,
- "animationAbsMaxBytes": 0,
- "sortOrder": 0,
- "isActive": true
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "type": "game_background",
- "displayName": "Game Background",
- "displayNames": {
- "tr": "Oyun Arka Planı",
- "en": "Game Background"
}, - "orientation": "landscape",
- "minWidth": 0,
- "minHeight": 0,
- "maxWidth": 0,
- "maxHeight": 0,
- "aspectMin": 0,
- "aspectMax": 0,
- "maxBytes": 0,
- "absMaxBytes": 0,
- "allowedMimeTypes": [
- "image/jpeg",
- "image/png",
- "image/webp"
], - "animationAllowed": true,
- "animationMaxBytes": 0,
- "animationAbsMaxBytes": 0,
- "sortOrder": 0,
- "id": 0,
- "isActive": true,
- "configPrefix": "game_background_image",
- "uploadDirSegment": "game_background",
- "absMaxWidth": 0,
- "absMaxHeight": 0,
- "createdAt": "string"
}, - "error": "string"
}| order required | Array of integers Full ordered list of background_types IDs. |
{- "order": [
- 0
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "type": "game_background",
- "displayName": "Game Background",
- "displayNames": {
- "tr": "Oyun Arka Planı",
- "en": "Game Background"
}, - "orientation": "landscape",
- "minWidth": 0,
- "minHeight": 0,
- "maxWidth": 0,
- "maxHeight": 0,
- "aspectMin": 0,
- "aspectMax": 0,
- "maxBytes": 0,
- "absMaxBytes": 0,
- "allowedMimeTypes": [
- "image/jpeg",
- "image/png",
- "image/webp"
], - "animationAllowed": true,
- "animationMaxBytes": 0,
- "animationAbsMaxBytes": 0,
- "sortOrder": 0,
- "id": 0,
- "isActive": true,
- "configPrefix": "game_background_image",
- "uploadDirSegment": "game_background",
- "absMaxWidth": 0,
- "absMaxHeight": 0,
- "createdAt": "string"
}, - "error": "string"
}Partial update. typeKey is immutable (not accepted).
| id required | integer >= 1 |
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 |
{- "displayNames": {
- "property1": "string",
- "property2": "string"
}, - "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": {
- "property1": "string",
- "property2": "string"
}, - "animationAllowed": true,
- "animationMaxBytes": 0,
- "animationAbsMaxBytes": 0,
- "sortOrder": 0,
- "isActive": true
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "type": "game_background",
- "displayName": "Game Background",
- "displayNames": {
- "tr": "Oyun Arka Planı",
- "en": "Game Background"
}, - "orientation": "landscape",
- "minWidth": 0,
- "minHeight": 0,
- "maxWidth": 0,
- "maxHeight": 0,
- "aspectMin": 0,
- "aspectMax": 0,
- "maxBytes": 0,
- "absMaxBytes": 0,
- "allowedMimeTypes": [
- "image/jpeg",
- "image/png",
- "image/webp"
], - "animationAllowed": true,
- "animationMaxBytes": 0,
- "animationAbsMaxBytes": 0,
- "sortOrder": 0,
- "id": 0,
- "isActive": true,
- "configPrefix": "game_background_image",
- "uploadDirSegment": "game_background",
- "absMaxWidth": 0,
- "absMaxHeight": 0,
- "createdAt": "string"
}, - "error": "string"
}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.
| id required | integer >= 1 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
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.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "transport": {
- "uploadMaxFilesizeBytes": 67108864,
- "postMaxSizeBytes": 167772160
}, - "surfaces": [
- {
- "surface": "gift",
- "slots": [
- {
- "key": "gift.image",
- "surface": "gift",
- "kind": "image",
- "labelKey": "media.slot.gift.image",
- "fields": {
- "property1": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}, - "property2": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}
}
}
]
}
]
}, - "error": "string"
}| slot required | string^[A-Za-z0-9._-]+$ Example: gift.image |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "key": "gift.image",
- "surface": "gift",
- "kind": "image",
- "labelKey": "media.slot.gift.image",
- "fields": {
- "property1": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}, - "property2": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}
}
}, - "error": "string"
}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.
| slot required | string^[A-Za-z0-9._-]+$ Example: gift.image |
| X-Admin-Actor | string <= 100 characters Recorded as |
| 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" |
{- "maxBytes": 1,
- "absMaxBytes": 1,
- "minWidth": 1,
- "minHeight": 1,
- "maxWidth": 1,
- "maxHeight": 1,
- "absMaxWidth": 1,
- "absMaxHeight": 1,
- "aspectMin": 0,
- "aspectMax": 0,
- "allowedMimeTypes": [
- "image/png"
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "key": "gift.image",
- "surface": "gift",
- "kind": "image",
- "labelKey": "media.slot.gift.image",
- "fields": {
- "property1": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}, - "property2": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}
}
}, - "error": "string"
}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.
| slot required | string^[A-Za-z0-9._-]+$ Example: gift.image |
| X-Admin-Actor | string <= 100 characters |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "key": "gift.image",
- "surface": "gift",
- "kind": "image",
- "labelKey": "media.slot.gift.image",
- "fields": {
- "property1": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}, - "property2": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}
}
}, - "error": "string"
}| 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 |
| X-Admin-Actor | string <= 100 characters |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "key": "gift.image",
- "surface": "gift",
- "kind": "image",
- "labelKey": "media.slot.gift.image",
- "fields": {
- "property1": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}, - "property2": {
- "effective": 0,
- "default": 0,
- "overridden": true,
- "exceedsTransportCeiling": true
}
}
}, - "error": "string"
}Cosmetic label CRUD — create, list, update, reorder, guarded delete (force flag for in-use labels)
| q | string Substring match against key or display name. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| offset | integer Default: 0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "items": [
- {
- "id": 1,
- "key": "featured",
- "displayNames": {
- "tr": "Öne Çıkan",
- "en": "Featured"
}, - "kind": "manual",
- "placement": "badge",
- "iconEmoji": "🆕",
- "iconType": "image",
- "computeRule": "string",
- "sortOrder": 10,
- "isActive": true,
- "createdAt": "2026-06-19 10:00:00",
- "updatedAt": "2026-06-19 10:00:00"
}
], - "total": 0,
- "limit": 0,
- "offset": 0
}, - "error": "string"
}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).
| 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. |
{- "key": "featured",
- "displayNames": {
- "tr": "Öne Çıkan",
- "en": "Featured"
}, - "kind": "manual",
- "placement": "badge",
- "computeRule": "string",
- "iconUrl": "/uploads/shop-labels/1_abc.webp",
- "iconEmoji": "🆕"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "id": 1,
- "key": "featured",
- "displayNames": {
- "tr": "Öne Çıkan",
- "en": "Featured"
}, - "kind": "manual",
- "placement": "badge",
- "iconEmoji": "🆕",
- "iconType": "image",
- "computeRule": "string",
- "sortOrder": 10,
- "isActive": true,
- "createdAt": "2026-06-19 10:00:00",
- "updatedAt": "2026-06-19 10:00:00"
}, - "error": "string"
}| order required | Array of integers Ordered list of label IDs. |
{- "order": [
- 0
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": {
- "id": 1,
- "key": "featured",
- "displayNames": {
- "tr": "Öne Çıkan",
- "en": "Featured"
}, - "kind": "manual",
- "placement": "badge",
- "iconEmoji": "🆕",
- "iconType": "image",
- "computeRule": "string",
- "sortOrder": 10,
- "isActive": true,
- "createdAt": "2026-06-19 10:00:00",
- "updatedAt": "2026-06-19 10:00:00"
}, - "error": "string"
}| id required | integer >= 1 |
| 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. |
{- "key": "string",
- "displayNames": {
- "property1": "string",
- "property2": "string"
}, - "kind": "manual",
- "placement": "badge",
- "computeRule": "string",
- "iconUrl": "string",
- "iconEmoji": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| id required | integer >= 1 |
| force | string Enum: "1" "true" "yes" "on" Force-delete even if label is in use. |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Lists every gifts row including inactive and unlisted entries.
All /admin/* routes are JWT-exempt and guarded by
AdminKeyMiddleware (X-Admin-Key header).
| q | string Substring match against |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer Default: 0 Example: offset=0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| displayName required | string User-facing label, 1–150 characters. Sets the display_name mirror. |
| displayNames | string JSON string locale map, e.g. |
| internalName | string Stable ops identifier, 1–120 characters. Must be globally
unique. When omitted/blank defaults to |
| 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. |
| 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 |
| image | string <binary> Gift image. SOFT constraints per |
| animation | string <binary> Optional SVGA animation file (v1 zip or v2 zlib container). |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
{- "status": "OK",
- "data": {
- "maxBytes": 2097152,
- "maxWidth": 512,
- "maxHeight": 512,
- "allowedMimeTypes": [
- "image/webp",
- "image/png",
- "image/jpeg"
], - "allowedExtensions": [
- "webp",
- "png",
- "jpg"
], - "animation": {
- "maxBytes": 10485760,
- "absMaxBytes": 31457280,
- "allowedExtensions": [
- "svga"
]
}
}
}Body must contain EVERY gift id exactly once; missing, extra,
duplicate, or unknown ids are rejected with 400 INVALID_ORDER
and nothing is written.
| order required | Array of integers Complete ordered list of all gift ids. |
{- "order": [
- 3,
- 1,
- 2
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| id required | integer Example: 7 |
| displayName | string User-facing label, 1–150 characters. Updates display_name mirror. |
| displayNames | string JSON string locale map, e.g. |
| 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 |
| 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. |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| id required | integer Example: 7 |
| 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. |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| id required | integer Example: 7 |
| add | Array of strings Label keys to assign. |
| remove | Array of strings Label keys to remove. |
{- "add": [
- "featured",
- "sale"
], - "remove": [
- "old-promo"
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| 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 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| order required | Array of integers Ordered list of shop_item IDs. |
{- "order": [
- 0
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| shopItemId required | integer >= 1 |
| add | Array of strings Label keys to add. |
| remove | Array of strings Label keys to remove. |
{- "add": [
- "string"
], - "remove": [
- "string"
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| kind required | string Enum: "package" "tier" "label" "atom" Selects the storage segment + validation rules. |
| image required | string <binary> The image file (webp/png/jpeg). |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Non-cosmetic shop_items rows whose payload + localized display live in attrs. JWT-exempt, X-Admin-Key guarded.
| kind | string Enum: "coin" "allowance" "privilege" |
| q | string Substring match against attrs JSON. |
| limit | integer Default: 50 |
| offset | integer Default: 0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Per-kind payload — coin {amount}; allowance {allowanceKey, amount}; privilege {privilegeKey, durationDays} (fulfillment pending). All accept displayNames ({tr,en}) + iconUrl.
| kind required | string Enum: "coin" "allowance" "privilege" |
required | object |
| isListed | boolean Default: false |
{- "kind": "coin",
- "attrs": { },
- "isListed": false
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| shopItemId required | integer >= 1 |
object | |
| isListed | boolean |
{- "attrs": { },
- "isListed": true
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Rejects with 409 ATOM_IN_USE (+details.packageCount) when referenced by a package; ?force=1 cascades.
| shopItemId required | integer >= 1 |
| force | string Value: "1" Force-delete even if referenced. |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| q | string Substring match against internal_name. |
| isActive | string Enum: "1" "0" |
| limit | integer Default: 50 |
| offset | integer Default: 0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| force | string Value: "1" Bypass the image-ratio soft-check. |
| 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 |
{- "internalName": "string",
- "title": {
- "property1": "string",
- "property2": "string"
}, - "subtext": {
- "property1": "string",
- "property2": "string"
}, - "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": [
- {
- "shopItemId": 0,
- "quantity": 1,
- "params": { }
}
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| id required | integer >= 1 |
| force | string Value: "1" Bypass the image-ratio soft-check. |
| 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 |
{- "internalName": "string",
- "title": {
- "property1": "string",
- "property2": "string"
}, - "subtext": {
- "property1": "string",
- "property2": "string"
}, - "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": [
- {
- "shopItemId": 0,
- "quantity": 1,
- "params": { }
}
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| 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 |
{- "key": "string",
- "displayNames": {
- "tr": "Efsanevi",
- "en": "Legendary"
}, - "color": "string",
- "backgroundColor": "string",
- "icon": "string",
- "iconUrl": "string",
- "sortOrder": 0,
- "isActive": true
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| order required | Array of integers |
{- "order": [
- 0
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| id required | integer >= 1 |
| 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 |
{- "key": "string",
- "displayNames": {
- "tr": "Efsanevi",
- "en": "Legendary"
}, - "color": "string",
- "backgroundColor": "string",
- "icon": "string",
- "iconUrl": "string",
- "sortOrder": 0,
- "isActive": true
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| id required | integer >= 1 |
| force | string Value: "1" |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| appUserId required | string Example: appUserId=905551112233 RevenueCat app user id ( |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| appUserId required | string Example: appUserId=905551112233 |
| limit | integer Default: 50 Example: limit=50 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| appUserId | string Example: appUserId=905551112233 Optional — when omitted, returns a global report (slow). |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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:
sourceTag — iap | refund | spend | reward | gift | admin | transfer | unknown. Reads metadata.source; falls back to
coin_history.type for pre-tagging-era rows
(type='purchase' → iap).environmentTag — PROD | SANDBOX | TEST. Reads
metadata.environment then metadata.rc_event.purchase_environment;
missing means PROD (treats pre-tagging rows as production).| callerId | string Example: callerId=905551112233 Exact match on |
| 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 |
| dateTo | string <date-time> Example: dateTo=2026-05-05T23:59:59Z Exclusive upper bound on |
| 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 ( |
{- "status": "OK",
- "items": [
- {
- "id": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "userId": 0,
- "phoneNumber": "string",
- "displayName": "string",
- "amount": 0,
- "balanceAfter": 0,
- "type": "string",
- "referenceId": "string",
- "metadata": { },
- "revenuecatSynced": true,
- "revenuecatSyncedAt": "2019-08-24T14:15:22Z",
- "revenuecatSyncAttempts": 0,
- "revenuecatLastError": "string",
- "revenuecatReason": "string",
- "revenuecatReference": "string",
- "sourceTag": "iap",
- "environmentTag": "PROD",
- "statusTag": "confirmed",
- "betCount": 1
}
], - "pagination": {
- "limit": 0,
- "offset": 0,
- "total": 0
}, - "kpis": {
- "totalInCirculation": 0,
- "iap24h": {
- "count": 0,
- "coins": 0
}, - "sandboxAndTest24h": {
- "count": 0,
- "coins": 0
}, - "spend24h": {
- "count": 0,
- "coins": 0
}, - "driftCount": 0
}, - "filters": {
- "sources": [
- "string"
], - "environments": [
- "string"
], - "statuses": [
- "string"
]
}
}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.
| 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 |
{- "period": {
- "fromMs": 1782777600000,
- "toMs": 1783814399000,
- "generatedAtMs": 0
}, - "scope": {
- "environment": "PROD",
- "realUsersOnly": true,
- "excludedSyntheticDev": true
}, - "totals": {
- "inflow": 8455000,
- "outflow": 7645000,
- "net": 810000,
- "txns": 347480,
- "ledgerRows": 347480
}, - "trend": [
- {
- "dayMs": 0,
- "net": 0,
- "cumulativeNet": 0
}
], - "kinds": [
- {
- "kind": "gifts",
- "role": "sink",
- "inflow": 0,
- "outflow": 1260000,
- "net": -1260000,
- "txns": 38700,
- "users": 7600,
- "volume": 1260000,
- "sources": [
- {
- "source": "gift_send",
- "amount": -720000,
- "txns": 4200,
- "top": [
- {
- "refType": "gift",
- "refId": "rose",
- "label": "Gül",
- "amount": 400,
- "txns": 12
}
]
}
]
}
], - "topUsers": [
- {
- "userId": 0,
- "callerId": "905551112233",
- "displayName": "bigspender",
- "spent": 142000,
- "earned": 38000,
- "net": -104000,
- "txns": 1240,
- "mainKind": "gifts"
}
]
}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>"| 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. |
{- "callerId": "string",
- "amount": 1,
- "note": "string",
- "idempotencyKey": "string",
- "adminUserId": "string"
}{- "status": "OK",
- "grant": {
- "ok": true,
- "ledgerId": 0,
- "userId": 0,
- "appUserId": "string",
- "granted": 0,
- "balance": 0,
- "type": "string",
- "referenceId": "string",
- "replayed": true
}
}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.
| 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
|
{- "status": "OK",
- "transfers": [
- {
- "transferId": 1042,
- "senderUserId": 17,
- "senderCallerId": "905550000001",
- "recipientUserId": 42,
- "recipientCallerId": "905551112233",
- "amount": 500,
- "feeCoins": 25,
- "netAmount": 475,
- "note": "iyi oyundu",
- "createdAtMs": 1754300000000
}
], - "hasMore": true,
- "cursor": 1042
}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.
| fromMs | integer <int64> Defaults to toMs minus 7 days. |
| toMs | integer <int64> Defaults to now. |
{- "status": "OK",
- "period": {
- "fromMs": 0,
- "toMs": 0,
- "generatedAtMs": 0
}, - "totals": {
- "transfers": 128,
- "grossCoins": 250000,
- "feeCoinsBurned": 12500,
- "distinctSenders": 40,
- "distinctRecipients": 55
}, - "topSenders": [
- {
- "userId": 42,
- "callerId": "905551112233",
- "displayName": "Ayşe",
- "coins": 5000,
- "transfers": 12,
- "distinctRecipients": 8,
- "distinctSenders": 6
}
], - "topRecipients": [
- {
- "userId": 42,
- "callerId": "905551112233",
- "displayName": "Ayşe",
- "coins": 5000,
- "transfers": 12,
- "distinctRecipients": 8,
- "distinctSenders": 6
}
], - "funnels": [
- {
- "userId": 42,
- "callerId": "905551112233",
- "displayName": "Ayşe",
- "coins": 5000,
- "transfers": 12,
- "distinctRecipients": 8,
- "distinctSenders": 6
}
], - "reciprocalPairs": [
- {
- "aUserId": 17,
- "bUserId": 42,
- "aToB": 300,
- "bToA": 250,
- "transfers": 2,
- "washedCoins": 250
}
]
}Admin monitor for the RevenueCat coin-sync pipeline (worker liveness, queue depth, recent failures)
Read-only inspector for the RevenueCat coin-sync pipeline. Surfaces:
revenuecat:worker:heartbeat). Status is active when the heartbeat
is < 10s old, stale when 10–30s old, and down past 30s or missing.revenuecat:coin_sync_queue, LIST) and retry queue
(revenuecat:coin_sync_retry, ZSET keyed by retry-at epoch ms).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.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.?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.ok / 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.
| 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 |
{- "status": "OK",
- "data": {
- "worker": {
- "status": "active",
- "lastHeartbeatMs": 0,
- "ageMs": 0
}, - "redis": {
- "mainQueue": 0,
- "retryQueue": 0
}, - "database": {
- "pendingNegativeSync": 0,
- "failedRetrying": 0,
- "syncedToday": 0,
- "abandonedByReason": {
- "GHOST_USER": 0,
- "INVALID_DATA": 0,
- "RC_FATAL_OTHER": 0,
- "MAX_ATTEMPTS": 0
}, - "abandonedTotal": 0
}, - "items": [
- {
- "id": 0,
- "phoneNumber": "string",
- "amount": 0,
- "type": "string",
- "referenceId": "string",
- "revenuecatReference": "string",
- "revenuecatSynced": true,
- "revenuecatSyncAttempts": 0,
- "revenuecatLastError": "string",
- "revenuecatReason": "GHOST_USER",
- "revenuecatSyncedAt": "string",
- "createdAt": "string"
}
], - "pagination": {
- "limit": 0,
- "offset": 0,
- "total": 0
}, - "overallHealth": "ok",
- "serverTimeMs": 0
}
}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.
| ids | Array of integers[ items >= 1 ] Explicit |
| 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 |
| limit | integer [ 1 .. 500 ] Default: 100 Cap on rows to re-enqueue per call. Default 100, max 500. |
{- "ids": [
- 39,
- 40,
- 41,
- 42,
- 43
]
}{- "status": "OK",
- "data": {
- "requeued": 0,
- "ids": [
- 0
], - "skipped": 0,
- "message": "string"
}
}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.
| coinHistoryId required | integer >= 1 The |
| operatorId required | string Identifier of the admin performing the reconcile. Stored on
the audit row's |
| operatorReason required | string Free-text justification (Turkish or English). Stored on the
audit row's |
| dryRun | boolean Default: false When |
{- "operatorId": "admin@telpass",
- "operatorReason": "RC was zeroed at launch; balances now match",
- "dryRun": true
}{- "status": "OK",
- "data": {
- "dryRun": true,
- "originalRowId": 0,
- "originalReason": "string",
- "callerId": "string",
- "mysqlBalance": 0,
- "rcBalanceBefore": 0,
- "rcDeltaWouldApply": 0,
- "rcBalanceAfter": 0,
- "auditReferenceId": "string"
}
}Live RevenueCat product catalog (offerings/packages/products) read-through for panels
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.
| 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 |
{- "status": "OK",
- "offerings": [
- {
- "id": "string",
- "lookupKey": "string",
- "displayName": "string",
- "isCurrent": true,
- "types": [
- "string"
], - "metadata": null,
- "packages": [
- {
- "id": "string",
- "lookupKey": "string",
- "displayName": "string",
- "position": 0,
- "products": [
- {
- "id": "string",
- "storeIdentifier": "string",
- "displayName": "string",
- "type": "subscription",
- "priceMicros": 0,
- "currency": "string",
- "eligibilityCriteria": "string"
}
]
}
]
}
], - "meta": {
- "count": 0,
- "requestedTypes": [
- "string"
], - "availableTypes": [
- "string"
], - "fromCache": true,
- "builtAtMs": 0
}
}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:
used — is_used = 1expired — is_used = 0 AND expires_at < NOW()pending — is_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.
| phone | string Example: phone=90555 Substring match against |
| 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 |
| minAttemptCount | integer >= 0 Example: minAttemptCount=3 Floor on |
| dateFrom | string <date-time> Example: dateFrom=2026-05-01T00:00:00Z Inclusive lower bound on |
| dateTo | string <date-time> Example: dateTo=2026-05-05T23:59:59Z Exclusive upper bound on |
| limit | integer [ 1 .. 200 ] Default: 50 Example: limit=50 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "items": [
- {
- "id": 0,
- "phone": "string",
- "purpose": "login",
- "status": "used",
- "hasCode": true,
- "code": "string",
- "ipAddress": "string",
- "attemptCount": 0,
- "isUsed": true,
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "usedAt": "2019-08-24T14:15:22Z"
}
], - "pagination": {
- "limit": 0,
- "offset": 0,
- "total": 0
}, - "kpis": {
- "total": 0,
- "used": 0,
- "expired": 0,
- "pending": 0,
- "uniquePhones": 0
}, - "filters": {
- "purposes": [
- "string"
], - "statuses": [
- "string"
]
}
}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.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Returns the IVR-derived VIP status for the given callerId. Same
active-VIP rule as /vip/me: uuid set AND subscriptionsEnd
in the future.
| callerId required | string Phone number in callerid form (digits only; leading |
{- "callerId": "string",
- "data": {
- "isVip": true,
- "status": "active",
- "source": "live",
- "stale": true,
- "tier": "vip",
- "productId": "string",
- "startedAtMs": 0,
- "expiresAtMs": 0
}
}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).
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).
| 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 |
{- "rooms": [
- {
- "roomId": "string",
- "roomName": "string",
- "rtcRoomId": "string",
- "zegoRoomID": "string",
- "type": "group",
- "hostCallerId": "string",
- "listenerCount": 0,
- "speakerCount": 0,
- "createdAt": "string",
- "createdAtMs": 0
}
], - "total": 0,
- "page": 0,
- "limit": 0
}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).
{- "rooms": [
- {
- "id": 0,
- "zegoRoomId": "string",
- "roomId": "string",
- "roomName": "string",
- "type": "string",
- "maxUser": 0,
- "maxSeat": 0,
- "currentSipUsers": 0,
- "publishers": 0,
- "priority": 0,
- "status": 0
}
]
}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.
{- "rooms": [
- {
- "zegoRoomId": "string",
- "sipRoomId": 0,
- "roomId": "string",
- "roomName": "string",
- "type": "string",
- "presentInApp": true,
- "presentInIvr": true,
- "seatCount": 0,
- "appCount": 0,
- "ivrCount": 0,
- "maxUser": 0,
- "currentSipUsers": 0,
- "priority": 0,
- "status": 0
}
]
}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.
| id required | integer |
| maxUser | integer >= 0 |
| priority | integer >= 0 |
| status | integer Enum: 0 1 |
{- "maxUser": 0,
- "priority": 0,
- "status": 0
}{- "data": {
- "id": 0,
- "zegoRoomId": "string",
- "roomId": "string",
- "roomName": "string",
- "type": "string",
- "maxUser": 0,
- "maxSeat": 0,
- "currentSipUsers": 0,
- "publishers": 0,
- "priority": 0,
- "status": 0
}, - "warnings": [
- {
- "code": "SIP_CAP_EXCEEDS_SEATS",
- "details": {
- "maxUser": 0,
- "seatCount": 0
}
}
]
}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.
| id required | integer |
| revokedBy | string or null Optional operator label (default |
{- "revokedBy": "operator-42"
}{- "error": {
- "code": "ROOM_NOT_FOUND",
- "message": "Oda bulunamadı.",
- "details": { }
}
}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}).
| roomID required | string |
| userKey required | string Callerid of the user to eject, or |
| 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,
|
| issuedBy | string or null Optional operator label (default |
| alsoRestrict | boolean Default: false When true, an app-wide |
| restrictExpiresAtMs | integer or null <int64> Used together with |
{- "userKey": "905551234567",
- "reasonCode": "harassment",
- "note": "mikrofonda küfür",
- "cooldownSeconds": 600,
- "issuedBy": "operator-42",
- "alsoRestrict": false,
- "restrictExpiresAtMs": 1730000000000
}{- "ok": true,
- "kickId": 12,
- "roomId": "string",
- "userKey": "string",
- "cooldownExpiresAtMs": 1730000600000,
- "seatFreed": true,
- "rtcDisconnected": true,
- "restrictionId": 0,
- "warnings": [
- {
- "code": "RESTRICTION_NOT_APPLIED"
}
]
}Feeds the Active ejections section of the panel drawer. By default
it returns only active (unexpired, unlifted) blocks;
includeExpired=1 includes history as well.
| roomID required | string |
| includeExpired | boolean Default: false |
| limit | integer [ 1 .. 200 ] Default: 50 |
| offset | integer >= 0 Default: 0 |
{- "kicks": [
- {
- "id": 0,
- "roomId": "string",
- "userKey": "string",
- "reasonCode": "harassment",
- "note": "string",
- "issuedBy": "admin",
- "revokedBy": "string",
- "expiresAtMs": 0,
- "createdAtMs": 0,
- "revokedAtMs": 0,
- "isActive": true
}
]
}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}.
| roomID required | string |
{- "error": {
- "code": "ROOM_NOT_FOUND",
- "message": "Oda bulunamadı.",
- "details": { }
}
}Monitoring panel — app-wide online user count (real / synthetic), built
on the Redis presence keys (X-Admin-Key).
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).
{- "total": 4213,
- "real": 3105,
- "synthetic": 1103,
- "dev": 5,
- "asOfMs": 1719100000000,
- "stale": false,
- "calls": {
- "activeCalls": 12,
- "callersInCalls": 24,
- "stale": false
}
}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).
{- "asOfMs": 1719100000000,
- "total": 2,
- "stale": false,
- "users": [
- {
- "callerId": "905550099001",
- "lastSeenMs": 1719100000000,
- "kind": "real"
}
]
}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.
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.
| 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 |
| 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 |
| issuedBy | string Operator-label filter. |
| createdFromMs | integer <int64>
|
| createdToMs | integer <int64>
|
| limit | integer [ 1 .. 200 ] Default: 50 |
| offset | integer >= 0 Default: 0 |
{- "status": "OK",
- "data": [
- {
- "id": 42,
- "scope": "ip",
- "reasonCode": "harassment",
- "subjectValue": "203.0.113.0/24",
- "reason": "abuse",
- "expiresAtMs": 1730000000000,
- "createdAtMs": 1719100000000,
- "revokedAtMs": null,
- "issuedBy": "admin",
- "revokedBy": null,
- "subject": {
- "callerId": "905551112233",
- "displayName": "mehmet",
- "username": "mehmet",
- "accountType": "user",
- "status": "active"
}, - "correlation": {
- "accounts": 3,
- "devices": 2,
- "ips": 4,
- "confidence": "strong"
}
}
], - "total": 137
}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.
| scope required | string Enum: "user" "device" "ip" |
| value required | string
|
| 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 |
| 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 |
{- "scope": "ip",
- "value": "203.0.113.0/24",
- "reasonCode": "harassment",
- "reason": "çirkin dil",
- "expiresAtMs": 1730000000000,
- "issuedBy": "operator-42"
}{- "status": "OK",
- "data": {
- "id": 42,
- "scope": "ip",
- "reasonCode": "harassment",
- "subjectValue": "203.0.113.0/24",
- "reason": "abuse",
- "expiresAtMs": 1730000000000,
- "createdAtMs": 1719100000000,
- "revokedAtMs": null,
- "issuedBy": "admin",
- "revokedBy": null
}
}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.
| id required | integer <int64> |
| revokedBy | string |
{- "revokedBy": "operator-42"
}{- "status": "OK",
- "data": {
- "lifted": true
}
}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.
| id required | integer <int64> |
{- "status": "OK",
- "data": {
- "subject": {
- "scope": "ip",
- "value": "203.0.113.0/24",
- "label": "203.0.113.0/24"
}, - "identity": {
- "callerId": "905551112233",
- "displayName": "mehmet",
- "username": "mehmet",
- "accountType": "user",
- "status": "active"
}, - "accounts": [
- {
- "callerId": "905551112233",
- "displayName": "mehmet",
- "username": "mehmet",
- "avatarUrl": "string",
- "accountType": "user",
- "status": "active",
- "via": "device",
- "banState": "clean",
- "firstSeenMs": 1719100000000,
- "lastSeenMs": 1730000000000,
- "hitCount": 12
}
], - "devices": [
- {
- "deviceId": "auto-9f2c…",
- "isSynthetic": true,
- "tokenHashKnown": false,
- "accountCount": 1,
- "banState": "clean",
- "firstSeenMs": 1719100000000,
- "lastSeenMs": 1730000000000,
- "hitCount": 12,
- "confidence": {
- "level": "strong",
- "reason": "ip_fanout_inferred",
- "inferredFrom": {
- "accountCount": 41,
- "fanoutCeiling": 8
}
}
}
], - "ips": [
- {
- "ip": "203.0.113.7",
- "accountCount": 3,
- "sessionCount": 41,
- "banState": "clean",
- "firstSeenMs": 1719100000000,
- "lastSeenMs": 1730000000000,
- "confidence": {
- "level": "strong",
- "reason": "ip_fanout_inferred",
- "inferredFrom": {
- "accountCount": 41,
- "fanoutCeiling": 8
}
}
}
], - "confidence": {
- "level": "strong",
- "links": [
- {
- "kind": "ip",
- "value": "203.0.113.7",
- "level": "strong",
- "reason": "ip_fanout_inferred",
- "inferredFrom": {
- "accountCount": 41,
- "fanoutCeiling": 8
}
}
]
}, - "coverage": {
- "windowDays": 180,
- "oldestSightingMs": 1719100000000,
- "backfilled": false
}
}
}Shared moderation reference — the fixed reason-code catalog used by bans
and restrictions (X-Admin-Key). See docs/systems/bans.md.
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.
{- "status": "OK",
- "data": [
- "harassment"
]
}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.
| userKey required | string The user's callerid. |
{- "status": "OK",
- "data": {
- "devices": [
- {
- "deviceId": "abc123def",
- "deviceType": "android",
- "appVersion": "1.2.3",
- "lastSeenMs": 1730000000000
}
], - "ips": [
- {
- "ip": "203.0.113.7",
- "sessionCount": 12,
- "lastSeenMs": 1730000000000
}
]
}
}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.
| scope required | string Enum: "user" "device" "ip" Subject scope. Any other value returns |
| value required | string Subject value: a callerid, a device identifier or an IP/CIDR. |
{- "status": "OK",
- "data": {
- "accountsAffected": 7,
- "alreadyBanned": 2,
- "clean": 5,
- "sample": [
- {
- "callerId": "905551112233",
- "displayName": "mehmet",
- "username": "mehmet",
- "avatarUrl": "string",
- "accountType": "user",
- "status": "active",
- "via": "device",
- "banState": "clean",
- "firstSeenMs": 1719100000000,
- "lastSeenMs": 1730000000000,
- "hitCount": 12
}
]
}
}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.
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.
| 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 |
| 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 |
| issuedBy | string Operator-label filter. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| offset | integer >= 0 Default: 0 |
{- "status": "OK",
- "data": [
- {
- "id": 42,
- "userKey": "905551112233",
- "capability": "no_call",
- "reasonCode": "harassment",
- "note": "çirkin dil",
- "expiresAtMs": 1730000000000,
- "createdAtMs": 1719100000000,
- "revokedAtMs": null,
- "issuedBy": "admin",
- "revokedBy": null,
- "subject": {
- "callerId": "905551112233",
- "displayName": "mehmet",
- "username": "mehmet",
- "accountType": "user",
- "status": "active"
}, - "correlation": {
- "accounts": 3,
- "devices": 2,
- "ips": 4,
- "confidence": "strong"
}
}
], - "total": 42
}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.
| 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 |
| note | string Free-text moderator note (internal). |
| expiresAtMs | integer or null <int64> The restriction is permanent when omitted. |
| issuedBy | string Optional operator label (default |
{- "userKey": "905551112233",
- "capability": "no_call",
- "capabilities": [
- "no_call",
- "no_dm"
], - "reasonCode": "harassment",
- "note": "çirkin dil",
- "expiresAtMs": 1730000000000,
- "issuedBy": "operator-42"
}{- "status": "OK",
- "data": {
- "id": 42,
- "userKey": "905551112233",
- "capability": "no_call",
- "reasonCode": "harassment",
- "note": "çirkin dil",
- "expiresAtMs": 1730000000000,
- "createdAtMs": 1719100000000,
- "revokedAtMs": null,
- "issuedBy": "admin",
- "revokedBy": null
}
}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.
| id required | integer <int64> |
| revokedBy | string |
{- "revokedBy": "operator-42"
}{- "status": "OK",
- "data": {
- "lifted": true
}
}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.
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.
| id required | integer <int64> The user_profile_images row id. |
| decision required | string Enum: "approve" "reject" The moderator's decision. |
| reviewedBy | string <= 191 characters Moderator identity (the admin the panel authenticated). Defaults to |
| reasonCode | string <= 40 characters Fixed reason code. Required when rejecting. Rejection codes:
|
| reason | string <= 255 characters Free-text note (optional). |
{- "decision": "approve",
- "reviewedBy": "ahmet.k",
- "reasonCode": "offensive",
- "reason": "string"
}{- "status": "OK",
- "data": {
- "id": 4821,
- "status": "rejected",
- "reviewedAtMs": 1752660000000
}
}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.
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.
| week required | string Example: 2026-W30 ISO week key in UTC, or |
{- "weekId": "string",
- "status": "open",
- "startsAtMs": 0,
- "endsAtMs": 0,
- "ruleVersion": 0,
- "poolTotal": 0,
- "carriedIn": 0,
- "carriedOut": 0,
- "boards": [
- {
- "board": "string",
- "ranked": 0,
- "topPoints": 0.1
}
], - "ineligible": [
- {
- "reason": "account_age",
- "count": 0
}
], - "rewards": [
- {
- "status": "claimable",
- "count": 0,
- "total": 0
}
]
}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.
| week required | string Example: 2026-W30 |
{- "weekId": "2026-W30",
- "users": 0,
- "boards": {
- "property1": 0,
- "property2": 0
}
}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".
| week required | string Example: 2026-W30 |
{- "weekId": "2026-W30",
- "status": "paid",
- "pool": 0,
- "awards": 0,
- "carriedOut": 0
}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.
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.
| userId required | integer >= 1 Numeric |
| periodKey | string Example: periodKey=2026-07-21 Daily period key |
{- "userId": 1234,
- "callerId": "905344546002",
- "periodKey": "2026-07-21",
- "tier": "free",
- "setSize": 5,
- "slots": [
- {
- "slotIndex": 0,
- "questId": 42,
- "code": "pool_game_okey",
- "title": "Okey Table",
- "category": "oyun",
- "threshold": 1,
- "progress": 0,
- "completed": true,
- "isActive": true,
- "rerolledAt": "string"
}
], - "rerolls": {
- "used": 0,
- "allowance": 1
}, - "ladder": {
- "completions": 2,
- "rungs": [
- {
- "index": 1,
- "amount": 50,
- "status": "locked"
}
]
}, - "weekly": {
- "daysCompleted": 2,
- "requiredDays": 3,
- "amount": 500,
- "status": "locked"
}
}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.
| userId required | integer >= 1 |
| periodKey | string Example: periodKey=2026-07-21 Defaults to today in the quests timezone. |
{- "userId": 1234,
- "callerId": "905344546002",
- "periodKey": "2026-07-21",
- "tier": "free",
- "setSize": 5,
- "slots": [
- {
- "slotIndex": 0,
- "questId": 42,
- "code": "pool_game_okey",
- "title": "Okey Table",
- "category": "oyun",
- "threshold": 1,
- "progress": 0,
- "completed": true,
- "isActive": true,
- "rerolledAt": "string"
}
], - "rerolls": {
- "used": 0,
- "allowance": 1
}, - "ladder": {
- "completions": 2,
- "rungs": [
- {
- "index": 1,
- "amount": 50,
- "status": "locked"
}
]
}, - "weekly": {
- "daysCompleted": 2,
- "requiredDays": 3,
- "amount": 500,
- "status": "locked"
}, - "removed": 5
}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.
| userId required | integer >= 1 |
| slot required | integer >= 0 0-based slot index within the set. |
| periodKey | string Example: periodKey=2026-07-21 Defaults to today in the quests timezone. |
| questId required | integer The pooled quest to place in this slot. |
{- "questId": 42
}{- "userId": 1234,
- "callerId": "905344546002",
- "periodKey": "2026-07-21",
- "tier": "free",
- "setSize": 5,
- "slots": [
- {
- "slotIndex": 0,
- "questId": 42,
- "code": "pool_game_okey",
- "title": "Okey Table",
- "category": "oyun",
- "threshold": 1,
- "progress": 0,
- "completed": true,
- "isActive": true,
- "rerolledAt": "string"
}
], - "rerolls": {
- "used": 0,
- "allowance": 1
}, - "ladder": {
- "completions": 2,
- "rungs": [
- {
- "index": 1,
- "amount": 50,
- "status": "locked"
}
]
}, - "weekly": {
- "daysCompleted": 2,
- "requiredDays": 3,
- "amount": 500,
- "status": "locked"
}
}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.
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).
{- "status": "OK",
- "data": [
- {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}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.
| 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 ( |
| isPinned | boolean Default: false Pin this quest to the client's dedicated section. |
| scheduleStart | string or null <date-time> |
| scheduleEnd | string or null <date-time> |
| title required | string <= 128 characters Scalar fallback; when |
object Locale → title map (each value 1–128 chars). When present it is
authoritative; the scalar | |
| description | string or null |
object Locale → body-text map; same mirror semantics as | |
| sortOrder | integer Default: 0 |
{- "code": "daily_spend_v1",
- "period": "daily",
- "objectiveType": "coins_spent",
- "threshold": 100,
- "rewardType": "coins",
- "rewardPayload": {
- "amount": 50
}, - "audienceFilters": [
- {
- "type": "excludeSynthetic"
}
], - "isActive": false,
- "isFeatured": false,
- "isPinned": false,
- "scheduleStart": "2019-08-24T14:15:22Z",
- "scheduleEnd": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca.",
- "en": "Spend 100 coins today."
}, - "sortOrder": 0
}{- "status": "OK",
- "data": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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).
{- "status": "OK",
- "data": {
- "objectiveTypes": [
- "coins_spent",
- "games_played",
- "games_won",
- "seats_taken",
- "friends_added",
- "hearts_sent",
- "logins",
- "room_wheel_bets_placed",
- "room_wheels_spun",
- "room_wheels_won",
- "daily_wheels_spun"
], - "rewardTypes": [
- "coins"
], - "audiencePredicates": [
- "excludeSynthetic",
- "vipOnly",
- "newUsers"
], - "periods": [
- "daily",
- "weekly",
- "monthly",
- "one_time"
]
}
}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).
{- "status": "OK",
- "data": {
- "maxBytes": 10485760,
- "maxWidth": 2048,
- "maxHeight": 2048,
- "aspectMin": 1.32,
- "aspectMax": 1.79,
- "allowedMimeTypes": [
- "image/png",
- "image/jpeg",
- "image/svg+xml",
- "image/webp"
], - "allowedExtensions": [
- "png",
- "jpg",
- "svg",
- "webp"
]
}
}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.
{- "status": "OK",
- "data": {
- "ladderRungs": {
- "free": [
- 50,
- 100,
- 150
], - "vip": [
- 75,
- 125,
- 200
]
}, - "setSize": {
- "free": 5,
- "vip": 7
}, - "weeklyRewardAmount": 500,
- "weeklyRequiredDays": 3,
- "timezone": "Europe/Istanbul",
- "rerollAllowance": {
- "free": 0,
- "vip": 1,
- "managedIn": "allowances"
}, - "ladderTotals": {
- "free": 300,
- "vip": 400
}
}
}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.
object | |
object | |
| weeklyRewardAmount | integer [ 0 .. 1000000 ] |
| weeklyRequiredDays | integer [ 1 .. 7 ] |
{- "ladderRungs": {
- "free": [
- 1000000
], - "vip": [
- 1000000
]
}, - "setSize": {
- "free": 1,
- "vip": 1
}, - "weeklyRewardAmount": 1000000,
- "weeklyRequiredDays": 1
}{- "status": "OK",
- "data": {
- "ladderRungs": {
- "free": [
- 50,
- 100,
- 150
], - "vip": [
- 75,
- 125,
- 200
]
}, - "setSize": {
- "free": 5,
- "vip": 7
}, - "weeklyRewardAmount": 500,
- "weeklyRequiredDays": 3,
- "timezone": "Europe/Istanbul",
- "rerollAllowance": {
- "free": 0,
- "vip": 1,
- "managedIn": "allowances"
}, - "ladderTotals": {
- "free": 300,
- "vip": 400
}
}
}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.
| id required | integer >= 1 Quest definition ID. |
| from | integer <int64> Lower bound, epoch milliseconds (inclusive). |
| to | integer <int64> Upper bound, epoch milliseconds (inclusive). |
{- "status": "OK",
- "data": {
- "questId": 7,
- "progressed": 1240,
- "reached": 380,
- "claimed": 311,
- "from": 1718000000000,
- "to": 1718600000000
}
}| id required | integer >= 1 Quest definition ID. |
{- "status": "OK",
- "data": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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).
| id required | integer >= 1 |
| 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
|
| scheduleStart | string or null <date-time> |
| scheduleEnd | string or null <date-time> |
| title | string <= 128 characters Scalar patch — updates the |
object Replaces the whole locale → title map (values 1–128 chars). | |
| description | string or null |
object Replaces the locale → body-text map; | |
| sortOrder | integer |
{- "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": {
- "property1": "string",
- "property2": "string"
}, - "description": "string",
- "descriptions": {
- "property1": "string",
- "property2": "string"
}, - "sortOrder": 0
}{- "status": "OK",
- "data": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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).
| id required | integer >= 1 |
| 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
|
| 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 |
| 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. |
{- "status": "OK",
- "data": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Sets is_deleted=1, is_active=0. Existing progress and claim rows are
retained for audit. No retroactive changes to user state.
| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "deleted": true
}
}| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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.
| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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.
| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "id": 1,
- "code": "daily_spend_v1",
- "period": "daily",
- "objective_type": "coins_spent",
- "threshold": 100,
- "reward_type": "coins",
- "reward_payload": {
- "amount": 50
}, - "audience_filters": [
- {
- "type": "excludeSynthetic"
}
], - "is_active": 1,
- "is_featured": 0,
- "is_pinned": 0,
- "is_deleted": 0,
- "schedule_start": "2019-08-24T14:15:22Z",
- "schedule_end": "2019-08-24T14:15:22Z",
- "title": "Günlük Harcama",
- "titles": {
- "tr": "Günlük Harcama",
- "en": "Daily Spend"
}, - "description": "string",
- "descriptions": {
- "tr": "Bugün 100 coin harca, 50 coin kazan.",
- "en": "Spend 100 coins today, earn 50."
}, - "image_url": "/uploads/quest-images/1750000000_ab12cd34ef56.png",
- "sort_order": 10,
- "created_by": "string",
- "updated_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}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.
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).
| count | integer [ 1 .. 200 ] |
object (GenerationParams) Tuning knobs for draft generation. All fields optional; sensible defaults apply. |
{- "count": 10,
- "params": {
- "count": 10,
- "genderDistribution": {
- "male": 50,
- "female": 50,
- "other": 0
}, - "ageMin": 18,
- "ageMax": 45,
- "interestsMin": 2,
- "interestsMax": 5,
- "bioFillRatio": 0.8,
- "secondLanguages": [
- "en"
], - "ratingMin": 3.5,
- "ratingMax": 5,
- "heartCountMax": 500,
- "totalSwipesMax": 200,
- "createdAtSpreadDays": 180,
- "batchLabel": "parti-mayis",
- "seed": "string"
}
}{- "status": "OK",
- "data": {
- "drafts": [
- {
- "tempId": "string",
- "displayName": "Elif Yılmaz",
- "gender": "male",
- "age": 27,
- "birthDate": "1998-04-12",
- "bio": "string",
- "secondLanguage": "en",
- "rating": 4.6,
- "heartCount": 312,
- "totalSwipes": 88,
- "createdAtOffsetDays": 41,
- "interestIds": [
- 0
], - "avatarCosmeticItemId": 17,
- "avatarImageUrl": "string"
}
]
}
}Returns count (1–50) freshly rolled drafts using the supplied params.
Used to refresh individual rows or a selection in the draft staging table.
| count | integer [ 1 .. 50 ] |
object (GenerationParams) Tuning knobs for draft generation. All fields optional; sensible defaults apply. |
{- "count": 1,
- "params": {
- "count": 10,
- "genderDistribution": {
- "male": 50,
- "female": 50,
- "other": 0
}, - "ageMin": 18,
- "ageMax": 45,
- "interestsMin": 2,
- "interestsMax": 5,
- "bioFillRatio": 0.8,
- "secondLanguages": [
- "en"
], - "ratingMin": 3.5,
- "ratingMax": 5,
- "heartCountMax": 500,
- "totalSwipesMax": 200,
- "createdAtSpreadDays": 180,
- "batchLabel": "parti-mayis",
- "seed": "string"
}
}{- "status": "OK",
- "data": {
- "drafts": [
- {
- "tempId": "string",
- "displayName": "Elif Yılmaz",
- "gender": "male",
- "age": 27,
- "birthDate": "1998-04-12",
- "bio": "string",
- "secondLanguage": "en",
- "rating": 4.6,
- "heartCount": 312,
- "totalSwipes": 88,
- "createdAtOffsetDays": 41,
- "interestIds": [
- 0
], - "avatarCosmeticItemId": 17,
- "avatarImageUrl": "string"
}
]
}
}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.
required | Array of objects (DraftRecord) |
| batchLabel | string |
| seed | string or null |
{- "drafts": [
- {
- "tempId": "string",
- "displayName": "Elif Yılmaz",
- "gender": "male",
- "age": 27,
- "birthDate": "1998-04-12",
- "bio": "string",
- "secondLanguage": "en",
- "rating": 4.6,
- "heartCount": 312,
- "totalSwipes": 88,
- "createdAtOffsetDays": 41,
- "interestIds": [
- 0
], - "avatarCosmeticItemId": 17,
- "avatarImageUrl": "string"
}
], - "batchLabel": "panel",
- "seed": "string"
}{- "status": "OK",
- "data": {
- "created": 10,
- "failed": 0,
- "batchId": "a1b2c3d4e5f6a7b8",
- "errors": [
- "string"
]
}
}Batches grouped by batch_id with label, count, and timestamps.
{- "status": "OK",
- "data": {
- "batches": [
- {
- "batchId": "string",
- "label": "string",
- "count": 0,
- "createdAt": "string",
- "lastCreatedAt": "string",
- "seed": "string"
}
]
}
}Server-mode list backing the admin browse table.
| 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 |
{- "status": "OK",
- "data": {
- "rows": [
- {
- "id": 0,
- "callerid": "9990123456789012",
- "displayName": "string",
- "gender": "string",
- "age": 0,
- "heartCount": 0,
- "batchId": "string",
- "enabled": true,
- "avatarImageUrl": "string"
}
], - "total": 0
}
}| id required | integer |
{- "status": "OK",
- "data": {
- "id": 0,
- "callerid": "string",
- "displayName": "string",
- "enabled": true,
- "heartCount": 0,
- "totalSwipes": 0,
- "gender": "string",
- "bio": "string",
- "secondLanguage": "string",
- "rating": 0.1,
- "birthDate": "2019-08-24",
- "age": 0,
- "avatarCosmeticItemId": 0,
- "interestIds": [
- 0
]
}
}| id required | integer |
| 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 |
{- "displayName": "string",
- "bio": "string",
- "gender": "male",
- "birthDate": "2019-08-24",
- "rating": 0.1,
- "heartCount": 0,
- "totalSwipes": 0,
- "secondLanguage": "string",
- "avatarCosmeticItemId": 0,
- "interestIds": [
- 0
]
}{- "status": "OK",
- "data": { }
}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.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| key required | string |
required | string or integer or boolean or number Type-coerced server-side per the knob's declared type. |
{- "value": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Same handler as PUT for clients that can only POST.
| key required | string |
required | string or integer or boolean or number |
{- "value": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
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.
| userIds | Array of integers <int64> [ items <int64 > >= 1 ] One or more |
| callerIds | Array of strings Phone-style caller ids (digits only). Resolved server-side to
|
| 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 |
| 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
|
| pushEnabled | boolean Default: true When false, the row is created but the dispatcher immediately
marks it |
object Optional structured payload attached to the inbox row. Reserved
keys: | |
| idempotencyKey | string <= 96 characters Per-request idempotency key. Namespaced internally as
|
| createdBy | string <= 64 characters Free-text label persisted in the |
| redirectRoute | string or null <= 128 characters Opaque deep-link string echoed back to the Flutter client in the
FCM |
{- "userIds": [
- 42,
- 137
], - "callerIds": [
- "905322824781",
- "905329876543"
], - "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"
}{- "inserted": 0,
- "skippedDuplicates": 0,
- "skippedUnknown": 0,
- "messageIds": [
- 0
]
}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.
| userId | integer <int64> Example: userId=1042 Filter to a single recipient ( |
| 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 |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 Legacy offset paging. Ignored when |
| cursor | string Opaque keyset cursor over |
{- "items": [
- {
- "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"
}
], - "limit": 1,
- "offset": 0,
- "hasMore": true,
- "nextCursor": "string"
}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.
| audience required | string Enum: "all" "vip" "non_vip" v1 segments. |
| 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 |
{- "audience": "all",
- "title": "string",
- "body": "string",
- "scheduledAt": "2019-08-24T14:15:22Z",
- "metadata": { },
- "idempotencyKey": "string",
- "createdBy": "string"
}{- "jobId": 0,
- "replayed": true,
- "status": "pending"
}| id required | integer <int64> |
{- "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"
}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.
| id required | integer <int64> |
{- "deleted": true,
- "read": true,
- "disabled": true
}| id required | integer <int64> |
{- "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"
}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.
| templateKey required | string <= 64 characters Stable key (e.g. ftu_welcome_10min). Unique across templates. |
| titleTemplate required | string <= 255 characters Supports |
| bodyTemplate required | string Supports |
| 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 |
| 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 ( |
| variantNo | integer or null >= 1 1-based order within the pool; unique per head. Null on heads. |
{- "templateKey": "string",
- "titleTemplate": "string",
- "bodyTemplate": "string",
- "locale": "tr",
- "redirectRoute": "avatar_shop",
- "parentTemplateId": 0,
- "variantNo": 1
}{- "id": 0
}Newest first (id DESC). Two paging mechanisms: the keyset cursor
(preferred) and the legacy offset; cursor supersedes offset when
both are sent.
| 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 | string Opaque keyset cursor over |
{- "items": [
- {
- "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"
}
], - "limit": 0,
- "offset": 0,
- "hasMore": true,
- "nextCursor": "string"
}{- "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"
}| id required | integer |
| 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 |
{- "titleTemplate": "string",
- "bodyTemplate": "string",
- "locale": "string",
- "isActive": true,
- "redirectRoute": "string",
- "parentTemplateId": 0,
- "variantNo": 1
}{- "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"
}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.
| id required | integer |
{- "deleted": true,
- "read": true,
- "disabled": true
}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.
| ruleKey required | string <= 64 characters Stable handle for ops debugging. Unique across rules. |
| templateId required | integer Reference to an active |
| triggerEvent required | string Enum: "user_registered" "user_first_login" "user_returned_after_idle" "scheduled_recurring"
|
object or null Per-event params. Required for idle/recurring; optional for
inline events (kept null typically). Examples:
| |
object or null
| |
| delaySeconds | integer >= 0 Default: 0 Applied at fire time. |
| targetKind | string Default: "personal" Enum: "personal" "announcement"
|
| deliveryChannel | string Default: "inbox_push" Enum: "inbox_push" "push_only"
|
{- "ruleKey": "string",
- "templateId": 0,
- "triggerEvent": "user_registered",
- "triggerParams": { },
- "audienceFilter": { },
- "delaySeconds": 0,
- "targetKind": "personal",
- "deliveryChannel": "inbox_push"
}{- "id": 0
}Newest first (id DESC). Two paging mechanisms: the keyset cursor
(preferred) and the legacy offset; cursor supersedes offset when
both are sent.
| 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 | string Opaque keyset cursor over |
{- "items": [
- {
- "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"
}
], - "limit": 0,
- "offset": 0,
- "hasMore": true,
- "nextCursor": "string"
}{- "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"
}| id required | integer |
| 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
|
| deliveryChannel | string Enum: "inbox_push" "push_only" See create-request notes. Invalid value → 400 |
{- "templateId": 0,
- "triggerEvent": "user_registered",
- "triggerParams": { },
- "audienceFilter": { },
- "delaySeconds": 0,
- "isActive": true,
- "targetKind": "personal",
- "deliveryChannel": "inbox_push"
}{- "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"
}Hard delete is intentionally not supported. Existing
system_messages.rule_id rows would dangle.
| id required | integer |
{- "deleted": true,
- "read": true,
- "disabled": true
}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.{- "items": [
- {
- "key": "string",
- "label": "string",
- "description": "string",
- "scope": "global",
- "trigger": "user_registered",
- "sample": "string"
}
], - "byScope": {
- "global": [
- {
- "key": "string",
- "label": "string",
- "description": "string",
- "scope": "global",
- "trigger": "user_registered",
- "sample": "string"
}
], - "user": [
- {
- "key": "string",
- "label": "string",
- "description": "string",
- "scope": "global",
- "trigger": "user_registered",
- "sample": "string"
}
], - "trigger": [
- {
- "key": "string",
- "label": "string",
- "description": "string",
- "scope": "global",
- "trigger": "user_registered",
- "sample": "string"
}
]
}, - "byTrigger": {
- "property1": [
- "string"
], - "property2": [
- "string"
]
}, - "triggers": [
- {
- "key": "user_registered",
- "label": "string",
- "description": "string",
- "fires": "string",
- "params": [
- {
- "name": "string",
- "type": "int",
- "label": "string",
- "required": true,
- "min": 0,
- "max": 0,
- "default": null,
- "help": "string",
- "options": [
- {
- "value": "string",
- "label": "string"
}
]
}
], - "providesVars": [
- "string"
], - "supportsTargetKind": [
- "personal"
]
}
], - "personas": [
- {
- "id": "string",
- "label": "string",
- "description": "string",
- "appliesToTriggers": [
- "string"
], - "context": { }
}
], - "segments": [
- {
- "value": "all",
- "label": "string",
- "description": "string"
}
]
}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.
| audienceType required | string Enum: "all" "vip" "non_vip" "specific_users"
|
| title | string <= 255 characters Push banner title. Inbox sender label is a separate |
| 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 |
| 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 |
| templateId | integer or null <int64> FK to |
object or null Static context map merged into the per-recipient render context
(admin/rule-supplied). User-specific keys like | |
| redirectRoute | string or null <= 128 characters Per-blast deep-link override echoed back to the Flutter client in
the FCM |
{- "audienceType": "all",
- "title": "string",
- "body": "string",
- "scheduledAt": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z",
- "pushEnabled": true,
- "metadata": { },
- "recipientUserIds": [
- 0
], - "recipientCallerIds": [
- "string"
], - "idempotencyKey": "string",
- "createdBy": "string",
- "templateId": 0,
- "variables": { },
- "redirectRoute": "event:halloween_2026"
}{- "announcementId": 0,
- "replayed": true
}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.
| audienceType | string Enum: "all" "vip" "non_vip" "specific_users" |
| pushStatus | string Enum: "pending" "dispatching" "completed" "disabled" |
| since | string <date-time> Filter to rows with |
| activeOnly | string Value: "1" Pass |
| limit | integer [ 1 .. 200 ] Default: 30 |
| offset | integer >= 0 Default: 0 Legacy offset paging. Ignored when |
| cursor | string Opaque keyset cursor over |
{- "items": [
- {
- "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": {
- "property1": 0,
- "property2": 0
}, - "recipient_user_ids": [
- 0
]
}
], - "limit": 0,
- "offset": 0,
- "hasMore": true,
- "nextCursor": "string"
}| id required | integer <int64> |
{- "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": {
- "property1": 0,
- "property2": 0
}, - "recipient_user_ids": [
- 0
]
}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).
| id required | integer <int64> |
| validUntil | string or null <date-time> Send null to clear (never expires). |
| pushEnabled | boolean Setting this to false also flips push_status to |
| redirectRoute | string or null <= 128 characters Send null to clear. Affects only pushes fired after the patch — already-dispatched rows kept their stored value. |
{- "validUntil": "2019-08-24T14:15:22Z",
- "pushEnabled": true,
- "redirectRoute": "string"
}{- "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": {
- "property1": 0,
- "property2": 0
}, - "recipient_user_ids": [
- 0
]
}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.
| id required | integer <int64> |
| cancelledBy | string Identifier of the operator performing the cancel; defaults to "admin". |
{- "cancelledBy": "ops-berkk"
}{- "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": {
- "property1": 0,
- "property2": 0
}, - "recipient_user_ids": [
- 0
]
}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.
| id required | integer <int64> |
{- "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": {
- "recipients": 0,
- "surfaced": 0,
- "pushSent": 0,
- "pushFailed": 0,
- "pushNoToken": 0,
- "pushPending": 0,
- "opened": 0,
- "openRate": 0.1,
- "p50MinutesToRead": 0.1,
- "p90MinutesToRead": 0.1
}
}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.
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).
{- "config": {
- "quietHoursEnabled": true,
- "quietHoursStartMinute": 1260,
- "quietHoursEndMinute": 300,
- "maxCronPerUserPerDay": 3,
- "minIntervalSeconds": 300,
- "retentionMinIntervalSeconds": 1800,
- "poolEnabled": true,
- "poolNoRepeatDepth": 1
}
}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.
| quietHoursEnabled | boolean |
| quietHoursStartMinute | integer [ 0 .. 1439 ] |
| quietHoursEndMinute | integer [ 0 .. 1439 ] |
| maxCronPerUserPerDay | integer >= 0 |
| minIntervalSeconds | integer >= 0 |
| retentionMinIntervalSeconds | integer >= 0 |
| poolEnabled | boolean |
| poolNoRepeatDepth | integer >= 0 |
{- "poolNoRepeatDepth": 2,
- "quietHoursStartMinute": 1200
}{- "config": {
- "quietHoursEnabled": true,
- "quietHoursStartMinute": 1260,
- "quietHoursEndMinute": 300,
- "maxCronPerUserPerDay": 3,
- "minIntervalSeconds": 300,
- "retentionMinIntervalSeconds": 1800,
- "poolEnabled": true,
- "poolNoRepeatDepth": 1
}
}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.
{- "config": {
- "quietHoursEnabled": true,
- "quietHoursStartMinute": 1260,
- "quietHoursEndMinute": 300,
- "maxCronPerUserPerDay": 3,
- "minIntervalSeconds": 300,
- "retentionMinIntervalSeconds": 1800,
- "poolEnabled": true,
- "poolNoRepeatDepth": 1
}, - "rules": [
- {
- "ruleKey": "streak_at_risk_default",
- "triggerEvent": "streak_at_risk",
- "deliveryChannel": "inbox_push",
- "isInline": true,
- "countsTowardDailyCap": true,
- "window": {
- "kind": "hours",
- "startUtc": 23,
- "endUtc": 23,
- "hoursBeforeReset": 2,
- "resetHourUtc": 0
}, - "pool": {
- "head": {
- "id": 0,
- "templateKey": "t_streak_at_risk_v1",
- "variantNo": 0,
- "titleTemplate": "string",
- "bodyTemplate": "string",
- "redirectRoute": "string",
- "isActive": true,
- "sampleTitle": "string",
- "sampleBody": "12 günlük serin bu gece sona erebilir. Hemen girip serini kurtar."
}, - "variants": [
- {
- "id": 0,
- "templateKey": "t_streak_at_risk_v1",
- "variantNo": 0,
- "titleTemplate": "string",
- "bodyTemplate": "string",
- "redirectRoute": "string",
- "isActive": true,
- "sampleTitle": "string",
- "sampleBody": "12 günlük serin bu gece sona erebilir. Hemen girip serini kurtar."
}
]
}
}
], - "warnings": [
- {
- "ruleKey": "string",
- "message": "trigger_params çözümlenemedi."
}
]
}