Shuffly API (1.0.0)

Download OpenAPI specification:

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

auth

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

Refresh JWT access token

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

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

The refresh token from the last token pair.

Responses

Request samples

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

Response samples

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

Revoke refresh token (logout)

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

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

The refresh token from the last token pair.

Responses

Request samples

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

Response samples

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

Sign in with Google (mobile)

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

Request Body schema: application/json
required
idToken
required
string

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

displayName
string or null

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

deviceId
string

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

deviceType
string

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

firebaseToken
string

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

versionNo
string

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

appVersion
string

Alias for versionNo.

Responses

Request samples

Content type
application/json
{
  • "idToken": "string",
  • "displayName": "string",
  • "deviceId": "string",
  • "deviceType": "string",
  • "firebaseToken": "string",
  • "versionNo": "string",
  • "appVersion": "string"
}

Response samples

Content type
application/json
{
  • "userId": "+999102938475610",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "accessToken": "string",
  • "refreshToken": "string",
  • "tokenType": "Bearer",
  • "expiresAtMs": 0,
  • "refreshExpiresAtMs": 0,
  • "expiresAt": "2019-08-24T14:15:22Z",
  • "refreshExpiresAt": "2019-08-24T14:15:22Z",
  • "isNewUser": true,
  • "profile": {
    }
}

Sign in with Apple (mobile)

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

Request Body schema: application/json
required
identityToken
required
string

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

authorizationCode
string or null

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

givenName
string or null

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

familyName
string or null

See givenName.

deviceId
string

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

deviceType
string

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

firebaseToken
string

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

versionNo
string

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

appVersion
string

Alias for versionNo.

Responses

Request samples

Content type
application/json
{
  • "identityToken": "string",
  • "authorizationCode": "string",
  • "givenName": "string",
  • "familyName": "string",
  • "deviceId": "string",
  • "deviceType": "string",
  • "firebaseToken": "string",
  • "versionNo": "string",
  • "appVersion": "string"
}

Response samples

Content type
application/json
{
  • "userId": "+999102938475610",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "accessToken": "string",
  • "refreshToken": "string",
  • "tokenType": "Bearer",
  • "expiresAtMs": 0,
  • "refreshExpiresAtMs": 0,
  • "expiresAt": "2019-08-24T14:15:22Z",
  • "refreshExpiresAt": "2019-08-24T14:15:22Z",
  • "isNewUser": true,
  • "profile": {
    }
}

otp

Phone OTP send + verify (login flow)

Send a phone OTP

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

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

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

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

Request Body schema: application/json
required
telefon
required
string

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

phone
string

Alias for telefon.

Responses

Request samples

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

Response samples

Content type
application/json
{
  • "status": "OK",
  • "message": "OTP gönderildi.",
  • "expiresAt": "2026-04-30 12:05:00",
  • "resendAvailableAtMs": 1761820860000,
  • "debugOtp": "string",
  • "serviceResponse": "string"
}

Verify the OTP and issue session tokens

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

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

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

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

Alias for telefon.

otp
required
string

6-digit code.

kod
string

Alias for otp.

deviceId
string
deviceType
string

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

firebaseToken
string
versionNo
string
appVersion
string

Alias for versionNo.

Responses

Request samples

Content type
application/json
{
  • "telefon": "string",
  • "phone": "string",
  • "otp": "string",
  • "kod": "string",
  • "deviceId": "string",
  • "deviceType": "string",
  • "firebaseToken": "string",
  • "versionNo": "string",
  • "appVersion": "string"
}

Response samples

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

Alias of /otpSmsCheck

Same handler as /otpSmsCheck. Legacy alias.

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

Alias for telefon.

otp
required
string

6-digit code.

kod
string

Alias for otp.

deviceId
string
deviceType
string

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

firebaseToken
string
versionNo
string
appVersion
string

Alias for versionNo.

Responses

Request samples

Content type
application/json
{
  • "telefon": "string",
  • "phone": "string",
  • "otp": "string",
  • "kod": "string",
  • "deviceId": "string",
  • "deviceType": "string",
  • "firebaseToken": "string",
  • "versionNo": "string",
  • "appVersion": "string"
}

Response samples

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

user

User account and profile management

Update user + profile fields (admin-style)

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

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

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

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

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

YYYY-MM-DD.

avatar_id
integer

Ownership-gated — must be owned by the caller.

avatarId
integer

Alias.

bio
string
rating
number
property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "userId": 0,
  • "callerid": "string",
  • "status": "string",
  • "is_deleted": 0,
  • "last_login_at": "string",
  • "username": "string",
  • "full_name": "string",
  • "gender": "male",
  • "second_language": "string",
  • "birth_date": "string",
  • "avatar_id": 0,
  • "avatarId": 0,
  • "bio": "string",
  • "rating": 0
}

Response samples

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

Update profile fields (mobile client)

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

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

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

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

Alias.

username
string
bio
string <= 500 characters
about
string

Alias for bio.

birth_date
string

YYYY-MM-DD.

birthDate
string

Alias.

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

Alias.

avatar_id
integer

Ownership-gated — must be owned by the caller.

avatarId
integer

Alias.

Responses

Request samples

Content type
application/json
{
  • "userId": 0,
  • "full_name": "string",
  • "fullName": "string",
  • "username": "string",
  • "bio": "string",
  • "about": "string",
  • "birth_date": "string",
  • "birthDate": "string",
  • "gender": "male",
  • "second_language": "string",
  • "secondLanguage": "string",
  • "avatar_id": 0,
  • "avatarId": 0
}

Response samples

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

Soft-delete a user account

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

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

Alias for callerid.

phone
string

Alias for callerid.

Responses

Request samples

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

Response samples

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

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

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

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

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

country_code
string

Alias.

Responses

Request samples

Content type
application/json
{
  • "countryCode": "string",
  • "country_code": "string"
}

Response samples

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

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

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

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

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

Alias.

pool
boolean

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

forPool
boolean

Alias.

limit
integer [ 1 .. 30 ]
Default: 12

Responses

Request samples

Content type
application/json
{
  • "userId": "string",
  • "user_id": "string",
  • "userIds": [
    ],
  • "user_ids": null,
  • "pool": true,
  • "forPool": true,
  • "limit": 12
}

Response samples

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

Full user profile (mobile profile screen)

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

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

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

Alias.

userId
integer
user_id
integer

Alias.

Responses

Request samples

Content type
application/json
{
  • "callerId": "string",
  • "callerid": "string",
  • "userId": 0,
  • "user_id": 0
}

Response samples

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

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

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

Authorizations:
BearerAuth
path Parameters
callerId
required
string

Target user users.callerid (digits only).

Responses

Response samples

Content type
application/json
{
  • "userId": 0,
  • "userKey": "string",
  • "callerId": "string",
  • "displayName": "string",
  • "fullName": "string",
  • "username": "string",
  • "aliasName": "string",
  • "avatarId": 0,
  • "avatarUrl": "string",
  • "profileImageUrl": "string",
  • "profileImageStatus": "approved",
  • "gender": "male",
  • "secondLanguage": "string",
  • "birthDate": "string",
  • "age": 0,
  • "bio": "string",
  • "rating": 0,
  • "heartCount": 0,
  • "interests": [
    ],
  • "isFriend": true,
  • "isSelf": true,
  • "vip": {
    }
}

profile

Interest tags + first-time profile setup

List all active interest tags

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

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

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

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

header Parameters
Accept-Language
string
Example: tr

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

Responses

Response samples

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

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

Atomically upserts gender + second_language on user_profiles and replaces the user's user_profile_interests rows. Validates: gender ∈ {male, female, other}, secondLanguage non-empty, at least one valid interest id.

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

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

Responses

Request samples

Content type
application/json
{
  • "callerId": "string",
  • "gender": "male",
  • "secondLanguage": "string",
  • "interests": [
    ]
}

Response samples

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

List a user's selected interest tags

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

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

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

header Parameters
Accept-Language
string
Example: tr

Responses

Response samples

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

Replace a user's interest tags

callerId is in the query string (not body — legacy quirk). Body is { interests: [ids] }. Atomically wipes and re-inserts the user's user_profile_interests rows.

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

Responses

Request samples

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

Response samples

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

Update a user's secondLanguage

callerId in query, secondLanguage in body.

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

Responses

Request samples

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

Response samples

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

payment

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

List a user's purchase history

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

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

Responses

Request samples

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

Response samples

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

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

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

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

Responses

Request samples

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

Response samples

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

RevenueCat webhook (coin grants + VIP activations)

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

Behavior:

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

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

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

List in-app purchase packages

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

Authorizations:
BearerAuth

Responses

Response samples

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

HTML payment report (admin/internal)

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

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

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

4-digit year (defaults to current).

month
string
Example: month=5

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

Responses

Same as GET, with year/month in form body

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

Responses

Retry recently-failed PBX payment subscriptions

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

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

Authorizations:
BearerAuth

Responses

Response samples

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

Server-initiated coin spend (gift/unlock/etc.)

Atomically debits the user's user_wallet_summary balance, writes a coin_spend_transactions ledger row + coin_history entry, then best-effort notifies RevenueCat to mirror the spend (failures logged to rc_deduct_log, never returned to client).

Debits are guarded by row-lock + balance >= amount check so concurrent spends can't double-debit. The idempotency key is server-derived (spend-{userId}-{reason}-{rand}) — clients who need true idempotency across retries should use the wallet-skill pattern in newer endpoints (e.g. WalletService::spendCoins) instead.

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

Internal users.id (numeric).

amount
required
integer >= 1
reason
required
string

Free-form intent label (gift_send, vip_unlock, etc.).

targetUserId
integer or null

Optional gift recipient (users.id).

Responses

Request samples

Content type
application/json
{
  • "userId": 0,
  • "amount": 1,
  • "reason": "string",
  • "targetUserId": 0
}

Response samples

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

vip

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

Raw IVR subscription lookup by phone (legacy)

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

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

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

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

Responses

Request samples

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

Response samples

Content type
application/json
{ }

Cancel a user's active IVR subscription

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

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

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

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

Responses

Request samples

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

Response samples

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

Boolean VIP check (legacy)

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

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

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

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

Phone number. callerid is also accepted.

callerid
string

Responses

Request samples

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

Response samples

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

Proxy to the IVR subscriptions service

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

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

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

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

Phone number. telefon and phone are also accepted.

telefon
string
phone
string

Responses

Request samples

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

Response samples

Content type
application/json
{ }

Get the authenticated user's VIP status

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

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

Outcomes:

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

Responses

Response samples

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

ftu

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

Grant the FTU 30-minute free VIP trial

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

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

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

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

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

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

Authorizations:
BearerAuth

Responses

Response samples

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

Record FTU paywall dismissal and queue inbox offer

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

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

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

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

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

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

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

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

RC product id of the paywall that was dismissed.

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

Responses

Request samples

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

Response samples

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

FTU welcome-coin grant status

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

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

Status values

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

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

Authorizations:
BearerAuth

Responses

Response samples

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

wallet

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

Get current coin balance

Authorizations:
BearerAuth

Responses

Response samples

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

List coin history entries for the authenticated user

Paginated, newest-first feed of coin_history rows for the authenticated user. Rows join against coin_spend_transactions (for action_type / resource_*) and coin_products via revenuecat_webhook_events (for IAP-derived price_label).

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

1-indexed page number.

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

Rows per page.

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

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

Responses

Response samples

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

rewards

Daily reward claims

Claim daily streak rewards (MySQL-backed)

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

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

Behavior:

  • effectiveCap = min(upToDay, dayIndex), where dayIndex = LEAST(cycleLength, DATEDIFF(today, cycleStartDate) + 1).
  • All config rewards for days 1..effectiveCap that lack a row in daily_streak_claims for (user_id, cycle_id, day, type) are claimed; coin amounts are credited via WalletService::grantCoins with an idempotent reference_id (daily_streak:<cycleId>:days_<...>).
  • vipMinutes rewards are recorded but not credited (feature postponed); vipAdded and newVipMinutes always return 0.
  • Cycle rotation: if DATEDIFF(today, cycleStartDate) >= cycleLength, cycleStartDate advances by floor(diff/cycleLength) * cycleLength days (preserves weekday) and a new cycleId is generated. uninterrupted_streak increments only when the just-ending cycle was fully claimed AND the user returned within the next cycle window.
  • Unclaimed rewards at cycle rotation are forfeit.
Authorizations:
BearerAuth
query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

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

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

Upper bound (inclusive) of the day index to claim. The server clamps to min(upToDay, dayIndex) where dayIndex = LEAST(cycleLength, DATEDIFF(today, cycleStartDate) + 1). For "claim all visible today", pass the current dayIndex.

Responses

Request samples

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

Response samples

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

Read current daily-streak status (MySQL-backed)

Returns the user's current streak cycle, day index, and per-day reward list with claim state. Lazy-creates the user's streak row on first call and rotates the cycle when elapsed.

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.

claimed is tri-state:

  • true = already claimed in this cycle
  • false = eligible-but-unclaimed today
  • null = future day in this cycle, not yet eligible
Authorizations:
BearerAuth
query Parameters
callerid
required
string^\d{8,15}$
Example: callerid=905344546002

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

Responses

Response samples

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

friends

Friend requests, friendships, blocks, and relation checks

Send a friend request

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

User key of the target user.

message
string

Optional message to include with the request.

Responses

Request samples

Content type
application/json
{
  • "targetUserId": "string",
  • "message": "string"
}

Response samples

Content type
application/json
{
  • "requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
  • "dbId": 0,
  • "targetUserId": "string",
  • "status": "pending",
  • "createdAt": 0
}

List friend requests (incoming or outgoing)

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

Responses

Response samples

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

Accept a pending friend request

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

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

object

Optional key-value attributes for the friendship.

Responses

Request samples

Content type
application/json
{
  • "alias": "string",
  • "attributes": { }
}

Response samples

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

Reject a pending friend request

Authorizations:
BearerAuth
path Parameters
fromUserId
required
string

Responses

Response samples

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

List current user's friends

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

Responses

Response samples

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

Add a direct friendship (skip request flow)

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

Responses

Request samples

Content type
application/json
{
  • "targetUserId": "string",
  • "message": "string"
}

Response samples

Content type
application/json
{
  • "ok": true,
  • "targetUserId": "string",
  • "message": "string"
}

Remove a friend

Authorizations:
BearerAuth
path Parameters
friendUserId
required
string

Responses

Response samples

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

Block one or more users

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

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

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

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

List of user keys to block.

Responses

Request samples

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

Response samples

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

List blocked users

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

Responses

Response samples

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

Unblock a user (auto-restores prior friendship)

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

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

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

Authorizations:
BearerAuth
path Parameters
blockedUserId
required
string

Responses

Response samples

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

Check relation status with multiple users

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

List of user keys to check relation with.

Responses

Request samples

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

Response samples

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

aliases

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

List the requesting user's private aliases

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

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

Responses

Response samples

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

Fetch a single alias by target user

Authorizations:
BearerAuth
path Parameters
targetUserId
required
string

Responses

Response samples

Content type
application/json
{
  • "creatorUserId": "u_8f3c1a",
  • "targetUserId": "u_2bc09f",
  • "alias": "Aşkım",
  • "createdAt": 1714557600000,
  • "updatedAt": 1714557600000
}

Create or update an alias for a target user

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

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

Responses

Request samples

Content type
application/json
{
  • "alias": "Annem"
}

Response samples

Content type
application/json
{
  • "creatorUserId": "u_8f3c1a",
  • "targetUserId": "u_2bc09f",
  • "alias": "Aşkım",
  • "createdAt": 1714557600000,
  • "updatedAt": 1714557600000
}

Remove an alias

Authorizations:
BearerAuth
path Parameters
targetUserId
required
string

Responses

Response samples

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

voice-rooms

Voice room CRUD, membership, and heartbeat

List active voice rooms

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

Filter by room type.

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

Responses

Response samples

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

Create a new voice room

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

Kullanıcının seçtiği oda adı (name dolu ise title yok sayılır).

title
string <= 255 characters

Geriye dönük alan; name boşsa kullanılır.

zegoRoomID
required
string

Zego real-time engine room ID.

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

Oda görseli. GET /api/voice-rooms/client-config içindeki create-room görsel seçeneklerinden biri gönderilebilir. Boş veya geçersiz bir değer gönderilirse istek fail etmez ve backend image_url alanını boş bırakır.

Responses

Request samples

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

Response samples

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

Voice room client economy (env-aligned)

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

Authorizations:
BearerAuth

Responses

Response samples

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

Get voice room detail

Returns room snapshot for the authenticated user. Includes freeUsedToday, freeLimit, and extraSitsLeft (daily sit economy, same semantics as takeSeat).

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

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

Close a voice room (host only)

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

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

Join a voice room

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

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

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

Responses

Request samples

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

Response samples

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

Leave a voice room

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

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

Send heartbeat to keep presence active

voice_room_members.last_seen değerini yeniler ve is_speaker alanını istek gövdesindeki isSpeaker ile yazar. Aktif koltuk (voice_room_seat_timers) bu uç noktadan güncellenmez — koltuktan iniş için POST .../seats/leave kullanılmalıdır; yalnızca isSpeaker: false ile koltuk boşalmaz.

Üye heartbeat’i kesilirse periyodik eviction (VOICE_ROOM_HEARTBEAT_TIMEOUT_SECONDS) üyeliği ve gerekirse koltuğu temizler; seat_left{reason:"heartbeat_timeout"} yayınlanabilir.

Odaya yeniden girişte join veya bu endpoint last_seen’i tazelir; bu, aynı kullanıcı için TTL tabanlı eviction beklentisini fiilen sıfırlar (koltuk hâlâ seats/leave veya süre dolması ile kapanır).

WebSocket üzerinden speaker_status_changed yayınlanır.

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

Üyenin konuşmacı koltuğunda olduğunu (ürün tanımınıza göre) ifade eder. false gönderildiğinde sunucu voice_room_members.is_speaker = 0 yazar; aktif koltuk zamanlayıcısını kapatmaz — koltuk için seats/leave kullanın.

Responses

Request samples

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

Response samples

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

Send a heart to a user in the room

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

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

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

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

User key of the heart recipient.

Responses

Request samples

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

Response samples

Content type
application/json
{
  • "success": true,
  • "new_heart_count": 0
}

voice-room-seats

Voice room seat management and timers

Take a seat in a voice room

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

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

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

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

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

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

Responses

Request samples

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

Response samples

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

Leave a seat in a voice room

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

Responses

Request samples

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

Response samples

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

Extend own seat timer

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

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

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

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

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

Responses

Request samples

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

Response samples

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

Extend another user's seat timer (caller pays)

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

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

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

Optional client-supplied UUID. See ExtendSeatBody.

Responses

Request samples

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

Response samples

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

games

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

Pay the entry fee for a game and join it

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

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

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

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

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

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

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

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

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

app_page
string <= 64 characters

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

Responses

Request samples

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

Response samples

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

wheel

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

Get wheel configuration for the room

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

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

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

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

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

Get the room's current active round

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

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

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

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

Place a bet on the current round

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

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

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

Active round ID returned by GET /wheel/round.

sectionIndex
required
integer >= 0

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

amount
required
integer >= 1

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

idempotencyKey
required
string

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

Responses

Request samples

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

Response samples

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

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

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

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

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

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

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

Get a single round's detail (history)

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

Authorizations:
BearerAuth
path Parameters
roomID
required
string
roundID
required
integer

Responses

Response samples

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

Attach a wheel to the room (host only)

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

Semantics per room type:

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

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

Responses

Request samples

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

Response samples

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

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

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

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

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

Authorizations:
BearerAuth
path Parameters
roomID
required
string

Responses

Response samples

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

Global cross-room wheel winners leaderboard

Returns the top room-wheel winners across all rooms for a rolling time window. Ranking metric is gross winnings: SUM(payout_amount) over the user's resolved bets (won or lost) inside the window. Refunded and pending bets are excluded. Only users with positive winnings are returned. Each entry also exposes profit (= payout − stake) for clients that want to surface it secondarily, but it does not affect ordering.

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.

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

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

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

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

Rolling time window. Defaults to daily.

limit
integer [ 1 .. 100 ]
Default: 50

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

Responses

Response samples

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

wheel-daily

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

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

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

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

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

Authorizations:
BearerAuth

Responses

Response samples

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

Spend one daily spin and receive the reward

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

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

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

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

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

Responses

Request samples

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

Response samples

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

Get the caller's past daily spins

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

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

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

Responses

Response samples

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

Get the next daily-spin reset datetime (UTC)

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

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

Authorizations:
BearerAuth

Responses

Response samples

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

allowances

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

List all allowance balances for the authenticated user

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

Authorizations:
BearerAuth

Responses

Response samples

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

Get next daily-reset boundary and current server time

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

Authorizations:
BearerAuth

Responses

Response samples

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

Get a single allowance balance by item key

Authorizations:
BearerAuth
path Parameters
itemKey
required
string

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

Responses

Response samples

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

avatars

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

List avatars (catalog) annotated with ownership + active flags

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

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

Responses

Response samples

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

Purchase an avatar

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

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

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

Responses

Request samples

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

Response samples

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

List the caller's owned avatars

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

Authorizations:
BearerAuth

Responses

Response samples

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

Read the caller's currently active (equipped) avatar

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

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

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "id": 7,
  • "name": "Kara Kedi",
  • "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
}

Switch the caller's active avatar

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

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

Responses

Request samples

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

Response samples

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

profile-images

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

CDN callback — a new profile image entered the pipeline

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

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

Owner identifier (users.callerid — digits only).

assetId
required
string <= 64 characters

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

imageUrl
required
string <= 500 characters

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

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

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

object (ProfileImageLabels)

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

Responses

Request samples

Content type
application/json
{}

Response samples

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

CDN callback — moderation status transition for an asset

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

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

The CDN assetId recorded at ingest.

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

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

object (ProfileImageLabels)

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

Responses

Request samples

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

Response samples

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

Read the caller's own profile image and moderation status

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

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{}

call

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

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

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

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

Authorizations:
BearerAuth

Responses

Response samples

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

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

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

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

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

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

Authorizations:
BearerAuth
path Parameters
sessionUuid
required
string

The call_sessions.session_uuid the feedback belongs to.

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

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

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

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

issueText
string <= 280 characters

Optional free-text follow-up. Trimmed and truncated to 280 chars server-side; rejected if it contains http://, https://, or www.. Ignored when rating >= 4.

callDurationSec
integer >= 0

Client-claimed call duration in seconds (analytics-only; server also stores its own derived duration).

platform
string
Enum: "ios" "android"

Mobile platform string. Unknown values are stored as null.

appVersion
string <= 32 characters

Client app version (free-form, capped at 32 chars).

sentAtMs
integer <int64> >= 0

Client-side wall-clock timestamp (epoch ms) at submit time. Diagnostic only.

Responses

Request samples

Content type
application/json
{
  • "rating": 1,
  • "issueCode": "audio_cut",
  • "issueText": "string",
  • "callDurationSec": 0,
  • "platform": "ios",
  • "appVersion": "string",
  • "sentAtMs": 0
}

Response samples

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

Submit post-call user rating (social moderation)

Persists a 1..5 star rating left by one participant of a 1:1 call for the other. Distinct from quality-feedback, which collects audio/network issues; this endpoint collects social-moderation signals (inappropriate, underage, other).

Rules:

  • rating < 3 requires reasonCode.
  • reasonCode == "other" requires reasonText (max 240 chars).
  • For ratings >= 3, server drops any reason fields.
  • Idempotent on (userId, sessionUuid). A duplicate POST returns HTTP 200 with replayed: true and the original row; first POST returns HTTP 201.
  • Caller (JWT-bound userId = phone) must be one of the two participants of the session (call_sessions.user_phone1 or user_phone2); otherwise 403 FORBIDDEN.
Authorizations:
BearerAuth
path Parameters
sessionUuid
required
string

Call session UUID (= call_sessions.match_uuid).

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

Star rating from 1 to 5.

reasonCode
string or null
Enum: "inappropriate" "underage" "other" null

Required when rating < 3. Must be omitted/null otherwise (the server drops it for ratings >= 3).

reasonText
string or null <= 240 characters

Free-text explanation. Accepted only when reasonCode == "other"; trimmed to 240 chars and ignored for any other reason code or rating >= 3.

callDurationSec
integer or null >= 0

Client-reported call duration in seconds (analytics).

ratedAtMs
integer or null

Client clock at submission, epoch ms (analytics).

platform
string or null
Enum: "ios" "android" null

Mobile platform (analytics).

appVersion
string or null <= 32 characters

Mobile app version string (analytics).

Responses

Request samples

Content type
application/json
{
  • "rating": 1,
  • "reasonCode": "inappropriate",
  • "reasonText": "string",
  • "callDurationSec": 0,
  • "ratedAtMs": 0,
  • "platform": "ios",
  • "appVersion": "string"
}

Response samples

Content type
application/json
{
  • "ratingId": "e2685a8c-cb0d-4ffd-85d1-e4f8966e7aa9",
  • "sessionUuid": "string",
  • "rating": 1,
  • "reasonCode": "inappropriate",
  • "storedAtMs": 0,
  • "replayed": true
}

swipe

Legacy 1-on-1 swipe / call analytics counters

Record a 1-on-1 swipe action (legacy analytics)

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Increment a generic counter (legacy)

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Submit a 1-on-1 call rating

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Record a call session (legacy)

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Increment lifetime swipe counter

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

survey

Legacy survey send/check/recorded

Send/serve a survey to a user

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Check a user's survey state / response

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Mark a survey as completed/recorded

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

notify

Push-notification trigger (Firebase fan-out)

Send a push notification (Firebase fan-out)

Server-initiated notification trigger. Body shape is consumer-specific (caller passes through Firebase notification payload). Returns the underlying delivery result.

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

dm

DM push notifications. The DM transport itself rides on ZEGOCLOUD ZIM client-side; this backend only fans out FCM notifications to the receiver's registered devices when the sender's app calls /api/dm/notify after a successful ZIM send.

Trigger a push notification for a 1-1 DM just sent via ZIM

Fire-and-forget endpoint called by the sender's client immediately after ZIMKit().sendTextMessage(...) resolves. The backend never sees the message body for storage — Zego is the message transport; this endpoint exists solely to deliver an FCM push to the receiver's registered devices.

Idempotent on messageId (ZIM message id). A retry of the same messageId returns the original result without re-pushing.

Authentication is required; the sender is taken from the JWT, NOT from the body. receiverUserId is the receiver's users.callerid (digits-only string, same form ZIM uses as its userID).

Behaviour: if the receiver has globally muted DM pushes, no devices registered, or has blocked the sender, the call still returns 200 with delivered: 0 (errors never roll back the chat UI).

Authorizations:
BearerAuth
Request Body schema: application/json
required
messageId
required
string <= 64 characters

ZIM message id (ZIMMessage.messageID). Used as the idempotency key — duplicate calls with the same value return the original result without re-pushing.

conversationId
string

Stable conversation key. Defaults to receiver's callerid when omitted. Used for apns.thread-id / iOS group collapsing and for client-side routing back into the chat thread.

receiverUserId
required
string

Receiver's users.callerid (digits-only string). Same form ZIM uses for its userID, so the client can pass the peer's ZIM userID verbatim.

textPreview
string

Short plain-text preview to surface in the notification body. Truncated server-side to 140 characters; longer values are shortened with an ellipsis. Empty string is allowed (client may omit if the user has disabled previews locally).

createdAtMs
integer <int64>

Epoch milliseconds when the message was sent. Optional — if 0 or missing, the server stamps with its own clock.

Responses

Request samples

Content type
application/json
{
  • "messageId": "1745890000000123",
  • "conversationId": "5511999998888",
  • "receiverUserId": "5511999998888",
  • "textPreview": "Selam, naber?",
  • "createdAtMs": 1745890000000
}

Response samples

Content type
application/json
{
  • "delivered": 0,
  • "tokens": 0,
  • "replayed": true
}

account

Account-scoped settings: device/FCM token registration, profile bits. All endpoints require JWT auth and operate on the calling user.

Register or refresh the caller's FCM device token

Persists the caller's Firebase Cloud Messaging registration token into user_devices so DM push notifications can target this device.

Called by the Flutter client at two points:

  • App start — idempotent backfill so users provisioned before the device-tracking system landed (user_devices empty for them) start receiving push without needing to log out/in.
  • FirebaseMessaging.onTokenRefresh — token rotation, app reinstall, restore-from-backup, etc.

Replaces the never-routed legacy firebaseTokenUpdate endpoint.

deviceId is optional. When omitted the server synthesises one from sha256(userId:token) — stable per (user, token) but a rotation produces a new row (the prior row's stale token is nulled out by DmPushService on the next send attempt that hits UNREGISTERED).

Authorizations:
BearerAuth
Request Body schema: application/json
required
token
required
string [ 32 .. 4096 ] characters

FCM registration token (FirebaseMessaging.instance.getToken()). Persisted in user_devices.firebase_token and used by the DM push dispatcher.

deviceType
string

Free-form platform tag — typically ios or android.

appVersion
string

Client version for triage (any string the client uses).

deviceId
string

Stable per-install identifier the client persists locally (Hive UUID, etc.). When omitted the server synthesises an auto-<hash> deviceId — works fine, but token rotation creates a new user_devices row instead of updating the existing one. Sending a real deviceId is recommended once the client wires it in.

Responses

Request samples

Content type
application/json
{
  • "token": "fA9k_LdEQUe...zXq8YQ7Pr3",
  • "deviceType": "ios",
  • "appVersion": "9.3.2",
  • "deviceId": "8f1a3b62-ce1f-4e34-9d2d-71f6f6a96bd8"
}

Response samples

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

version

App force-update and service-health probes

Force-update / soft-update gating for the mobile client

The client posts its versionNo (or appVersion) and platform; the server returns whether the install needs to be updated, and whether the update is mandatory. Public route — no JWT required.

Request Body schema: application/json
required
versionNo
string
appVersion
string

Alias.

platform
string

e.g. android, ios.

property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "versionNo": "string",
  • "appVersion": "string",
  • "platform": "string"
}

Response samples

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

Update the server-side version registry (admin/internal)

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Check upstream / dependency health (legacy probe)

Public route — no JWT required.

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

point

Legacy loyalty-point currency (separate from coins)

Read a user's loyalty-point balance (legacy)

Loyalty points are a separate currency from coins (see /getCoinBalance). Used by the old reward / streak surface.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
integer
property name*
additional property
any

Responses

Request samples

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

Response samples

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

Subtract loyalty points (legacy)

Endpoint name preserves the original misspelling (Substract) for backward compatibility with mobile clients.

Authorizations:
BearerAuth
Request Body schema: application/json
required
userId
integer
amount
integer
property name*
additional property
any

Responses

Request samples

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

Response samples

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

Apply a points transaction (multi-purpose)

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

whatsapp

WhatsApp pairing / service multiplex (phantom service — verify)

Get a WhatsApp pairing QR code

Returns a QR-code URL or payload that the mobile client renders for WhatsApp account linking. The actual WhatsApp integration lives in the WhatsAppService (currently a phantom — see CLAUDE.md "Phantom services" — implementation is external/stubbed).

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

WhatsApp service multiplex (legacy)

Generic WhatsApp service multiplexer — the body's action field selects the underlying operation. See the WhatsAppService implementation (phantom; verify before relying on this endpoint).

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

Responses

Request samples

Content type
application/json
{ }

Response samples

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

test

Test-only seed and cleanup endpoints (refused outside APP_ENV=testing)

Idempotently create the JWT-auth caller as a `users` row (TEST ENV ONLY)

Inserts (or no-ops) the JWT subject's callerid into users so test scripts can sign in via OTP and immediately operate on a known user. Returns { userId, appUserId }.

Refused with 403 FORBIDDEN outside test env (APP_ENV !== 'testing').

Authorizations:
BearerAuth

Responses

Response samples

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

Wipe the JWT-auth caller's data (TEST ENV ONLY)

Deletes the caller's rows from wheel_rounds (daily-scope), reward_claims, coin_history, user_wallet_summary, and users (FK CASCADE handles user_allowances, user_allowance_history, voice-room rows via host_user_id, etc.). Used by the integration test runner between cases.

Refused with 403 FORBIDDEN outside test env.

Authorizations:
BearerAuth

Responses

Response samples

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

admin-allowances

Admin-panel surface — catalog CRUD, tier policies, grant rules, per-user grants, history

List allowance catalog items

Authorizations:
AdminKey
query Parameters
active
string
Enum: "true" "false"
Example: active=true

Filter by is_active (tri-state — omit for "all").

q
string
Example: q=avatar_slot

Partial match on item_key OR display_name_tr.

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

Responses

Response samples

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

Create a catalog item

Authorizations:
AdminKey
Request Body schema: application/json
required
itemKey
required
string <= 64 characters ^[a-z0-9_]+$
displayNameTr
required
string <= 128 characters
descriptionTr
string
maxCount
integer or null >= 0
expiryMode
required
string
Enum: "none" "per_balance" "per_grant"
expirySeconds
integer or null >= 1
params
any

Arbitrary JSON (object, array, or JSON-encoded string).

isActive
boolean
Default: true

Responses

Request samples

Content type
application/json
{
  • "itemKey": "string",
  • "displayNameTr": "string",
  • "descriptionTr": "string",
  • "maxCount": 0,
  • "expiryMode": "none",
  • "expirySeconds": 1,
  • "params": null,
  • "isActive": true
}

Response samples

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

Get a catalog item

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

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

Patch a catalog item (partial update)

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
non-empty
displayNameTr
string <= 128 characters
descriptionTr
string or null
maxCount
integer or null >= 0
expiryMode
string
Enum: "none" "per_balance" "per_grant"
expirySeconds
integer or null >= 1
params
any
isActive
boolean

Responses

Request samples

Content type
application/json
{
  • "displayNameTr": "string",
  • "descriptionTr": "string",
  • "maxCount": 0,
  • "expiryMode": "none",
  • "expirySeconds": 1,
  • "params": null,
  • "isActive": true
}

Response samples

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

Soft-disable a catalog item (sets `isActive=false`)

Hard delete is not exposed; user_allowance_history references would orphan.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

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

List tier policies for an item

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

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

Upsert a tier policy

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
tier
required
string
Enum: "normal" "vip"
Request Body schema: application/json
required
isEnforced
required
boolean
notes
string

Pass "" or omit to clear.

Responses

Request samples

Content type
application/json
{
  • "isEnforced": true,
  • "notes": "string"
}

Response samples

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

List grant sources

Authorizations:
AdminKey
query Parameters
active
string
Enum: "true" "false"
Example: active=true

Responses

Response samples

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

Create a grant source

Authorizations:
AdminKey
Request Body schema: application/json
required
sourceKey
required
string^[a-z0-9_]+$
displayName
required
string
isActive
boolean
Default: true

Responses

Request samples

Content type
application/json
{
  • "sourceKey": "string",
  • "displayName": "string",
  • "isActive": true
}

Response samples

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

Patch a grant source

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
non-empty
displayName
string
isActive
boolean

Responses

Request samples

Content type
application/json
{
  • "displayName": "string",
  • "isActive": true
}

Response samples

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

Soft-disable a grant source

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

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

List grant rules

Authorizations:
AdminKey
query Parameters
itemId
integer
Example: itemId=12
sourceId
integer
Example: sourceId=3
tier
string
Enum: "normal" "vip"
Example: tier=vip
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

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

Create a grant rule

(itemId, grantSourceId, tier) is unique — duplicates return 409 GRANT_RULE_CONFLICT.

Authorizations:
AdminKey
Request Body schema: application/json
required
itemId
required
integer
grantSourceId
required
integer
tier
required
string
Enum: "normal" "vip"
amount
required
integer >= 0
strategy
required
string
Enum: "set_to" "add"
refillIntervalSeconds
integer or null >= 1
isActive
boolean
Default: true
params
any

Responses

Request samples

Content type
application/json
{
  • "itemId": 0,
  • "grantSourceId": 0,
  • "tier": "normal",
  • "amount": 0,
  • "strategy": "set_to",
  • "refillIntervalSeconds": 1,
  • "isActive": true,
  • "params": null
}

Response samples

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

Get a grant rule

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

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

Patch a grant rule

itemId, grantSourceId, tier are immutable — create a new rule to change them.

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1
Request Body schema: application/json
required
non-empty
amount
integer >= 0
strategy
string
Enum: "set_to" "add"
refillIntervalSeconds
integer or null >= 1
isActive
boolean
params
any

Responses

Request samples

Content type
application/json
{
  • "amount": 0,
  • "strategy": "set_to",
  • "refillIntervalSeconds": 1,
  • "isActive": true,
  • "params": null
}

Response samples

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

Soft-disable a grant rule

Authorizations:
AdminKey
path Parameters
id
required
integer >= 1

Responses

Response samples

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

List or substring-search users by callerId

Authorizations:
AdminKey
query Parameters
q
string
Example: q=5678

Optional substring match on users.callerid (LIKE %q%). Empty/omitted lists all active users. SQL LIKE wildcards in the input (%, _, \) are escaped server-side and matched literally.

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

Responses

Response samples

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

Get all allowance balances for a user

Authorizations:
AdminKey
path Parameters
callerId
required
string <= 20 characters

Phone-format caller id (users.callerid), ≤20 chars.

Responses

Response samples

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

Admin grant (bypasses tier enforcement)

Writes directly to user_allowances.balance and user_allowance_history. Idempotent on referenceId — re-sending the same key returns the prior ledger row with replayed: true and strategy: "replay", with no mutation.

Authorizations:
AdminKey
path Parameters
callerId
required
string <= 20 characters

Phone-format caller id (users.callerid), ≤20 chars.

Request Body schema: application/json
required
itemKey
required
string
amount
required
integer >= 0
strategy
string
Default: "set_to"
Enum: "set_to" "add"
referenceId
string

Idempotency key. Server generates one when omitted, but callers are expected to provide a stable key — replays return the prior ledger row with replayed: true and no mutation.

object

Responses

Request samples

Content type
application/json
{
  • "itemKey": "string",
  • "amount": 0,
  • "strategy": "set_to",
  • "referenceId": "string",
  • "metadata": { }
}

Response samples

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

Admin debit (force-decrement)

Fails with 409 INVENTORY_EMPTY when balance would go below zero. Idempotent on referenceId. If the user's tier is unlimited for this item, returns {unlimited: true, balance: null} with no mutation.

Authorizations:
AdminKey
path Parameters
callerId
required
string <= 20 characters

Phone-format caller id (users.callerid), ≤20 chars.

Request Body schema: application/json
required
itemKey
required
string
amount
required
integer >= 1
referenceId
string
object

Responses

Request samples

Content type
application/json
{
  • "itemKey": "string",
  • "amount": 1,
  • "referenceId": "string",
  • "metadata": { }
}

Response samples

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

Query the allowance history ledger

Authorizations:
AdminKey
query Parameters
callerId
string
Example: callerId=905551112233

Exact match.

itemKey
string
Example: itemKey=free_seat

Exact match.

source
string
Enum: "grant" "consume" "expire" "refill" "admin" "refund"
Example: source=consume
from
string
Example: from=2026-05-01 00:00:00

Lower bound on created_atYYYY-MM-DD HH:MM:SS.

to
string
Example: to=2026-05-05 23:59:59

Upper bound on created_atYYYY-MM-DD HH:MM:SS.

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

Responses

Response samples

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

admin-avatars

Admin avatar catalog CRUD + tier rules

List avatars (paginated, filterable)

Lists rows from the avatars catalog. All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key).

Authorizations:
AdminKey
query Parameters
gender
string
Example: gender=female

Filter by gender (male, female, other).

usable
string
Example: usable=true

true/false to filter by is_usable. Tri-state — omit to get all.

listed
string
Example: listed=true

true/false to filter by is_listed. Tri-state — omit to get all.

active
string
Example: active=true

Deprecated alias for usable. Older admin-panel deeplinks still send this.

limit
integer
Default: 30
Example: limit=30
offset
integer
Default: 0
Example: offset=0

Responses

Response samples

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

Create a new avatar entry

Accepts optional tier (one of common|rare|epic|legendary, defaults to common) and is_default (boolean — at most one avatar may be marked as the system-wide default; UNIQUE).

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

1–100 characters

gender
string
Enum: "male" "female" "other"
price
integer >= 0
tier
string
Default: "common"
Enum: "common" "rare" "epic" "legendary"
is_default
boolean

Mark this avatar as the single system-wide default. Only one row may hold this flag.

is_usable
integer
Enum: 0 1

Whether the avatar may be used by anyone (renamed from is_active).

is_listed
integer
Enum: 0 1

Whether the avatar appears in shop listings. Owners can still wear an unlisted avatar.

sort_order
integer
property name*
additional property
any

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "gender": "male",
  • "price": 0,
  • "tier": "common",
  • "is_default": true,
  • "is_usable": 0,
  • "is_listed": 0,
  • "sort_order": 0
}

Response samples

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

Read avatar tier-policy rules

Returns the rules object used by the mobile client to gate avatar selection.

Authorizations:
AdminKey

Responses

Response samples

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

Read a single avatar by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

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

Update an avatar (POST instead of PUT — legacy admin clients)

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
property name*
additional property
any

Responses

Request samples

Content type
application/json
{ }

Response samples

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

Delete an avatar

Hard-delete. The service migrates every active user (user_profiles.avatar_id pointing here) onto the system default avatar before the row is removed; the FK on user_avatars is ON DELETE CASCADE, so ownership rows for this avatar disappear as well.

Rejected with:

  • AVATAR_IS_DEFAULT when the avatar is currently flagged is_default=1. Reassign the default to another avatar first, then retry.
  • NO_DEFAULT_AVAILABLE when active users currently wear this avatar AND the system has no other usable avatar to migrate them to. The admin panel should surface this as an explanatory dialog asking the operator to mark another avatar as default before retrying.
Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

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

admin-interests

Admin interest catalog CRUD + icon image upload

List interests (paginated, filterable, incl. inactive)

Lists rows from the interests catalog with localized names. All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key).

Authorizations:
AdminKey
query Parameters
active
string
Example: active=true

true/false to filter by is_active. Tri-state — omit to get all.

q
string

Substring match against code and name.

limit
integer
Default: 30
Example: limit=30
offset
integer
Default: 0
Example: offset=0

Responses

Response samples

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

Create a new interest (multipart; image required)

Creates an interest with a served icon image. image is required. names is a JSON object of localized display names; interests.name is the English-canonical fallback. The legacy emoji icon is optional.

Authorizations:
AdminKey
Request Body schema: multipart/form-data
required
code
required
string

1–50 chars, [a-z0-9_]+, unique.

name
required
string

1–100 characters (canonical/en fallback).

icon
string

Optional legacy emoji fallback.

isActive
boolean
Default: true
sortOrder
integer
Default: 0
names
string

JSON map of localized names, e.g. {"tr":"Yemek","en":"Food"}. Allowed locales: tr, en.

image
required
string <binary>

Icon image file (png/jpeg/webp, ≤2 MB, ≤512×512 by default).

Responses

Response samples

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

Read icon upload rules

Returns the icon upload constraints (max bytes, dimensions, allowed mime types/extensions).

Authorizations:
AdminKey

Responses

Response samples

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

Read a single interest by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

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

Update an interest (multipart; partial; POST instead of PUT — legacy admin clients)

Partial update. Supplying a new image replaces the icon and deletes the previous file. Supplying names upserts those locales.

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: multipart/form-data
code
string
name
string
icon
string
isActive
boolean
sortOrder
integer
names
string

JSON map of localized names.

image
string <binary>

Responses

Response samples

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

Hard-delete an interest

Hard-delete. FK ON DELETE CASCADE removes the interest from every user's selections (user_profile_interests, user_interests) and its interest_localized_info rows. To retire an interest without dropping user selections, set isActive=false via update instead. The icon file is removed (the shared _placeholder.png is never deleted).

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

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

admin-coin

Admin coin operations — RevenueCat balance/transactions + MySQL↔RC sync report

Read a user's coin balance from RevenueCat

Looks up the live virtual-currency balance from RevenueCat (admin operations + cross-system reconciliation). All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).

Authorizations:
AdminKey
query Parameters
appUserId
required
string
Example: appUserId=905551112233

RevenueCat app user id (users.callerid).

Responses

Response samples

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

List a user's RevenueCat virtual-currency transactions

Authorizations:
AdminKey
query Parameters
appUserId
required
string
Example: appUserId=905551112233
limit
integer
Default: 50
Example: limit=50

Responses

Response samples

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

MySQL ↔ RevenueCat reconciliation report

Returns drift between user_wallet_summary (MySQL source of truth) and RC's reported balance for the queried user(s). Used to debug coin-system inconsistencies — see .claude/plans/wallet-system.md.

Authorizations:
AdminKey
query Parameters
appUserId
string
Example: appUserId=905551112233

Optional — when omitted, returns a global report (slow).

Responses

Response samples

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

Paginated unified `coin_history` ledger view (admin panel)

Backs the admin-panel "Coin History" page. Reads coin_history joined with users.display_name, decorates each row with derived sourceTag / environmentTag / statusTag (so the panel doesn't reimplement the decoder), and returns top-strip KPIs in the same payload to keep auto-poll to one round-trip.

The two label axes:

  • sourceTagiap | refund | spend | reward | gift | admin | transfer | unknown. Reads metadata.source; falls back to coin_history.type for pre-tagging-era rows (type='purchase'iap).
  • environmentTagPROD | SANDBOX | TEST. Reads metadata.environment then metadata.rc_event.purchase_environment; missing means PROD (treats pre-tagging rows as production).
Authorizations:
AdminKey
query Parameters
callerId
string
Example: callerId=905551112233

Exact match on coin_history.phone_number.

source
string
Enum: "iap" "refund" "spend" "reward" "gift" "admin" "transfer"
Example: source=spend
environment
string
Enum: "PROD" "SANDBOX" "TEST"
Example: environment=PROD
status
string
Enum: "confirmed" "pending" "failed"
Example: status=confirmed
dateFrom
string <date-time>
Example: dateFrom=2026-05-01T00:00:00Z

Inclusive lower bound on created_at.

dateTo
string <date-time>
Example: dateTo=2026-05-05T23:59:59Z

Exclusive upper bound on created_at.

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

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "items": [
    ],
  • "pagination": {
    },
  • "kpis": {
    },
  • "filters": {
    }
}

Admin-driven test-coin grant (X-Admin-Key authed)

Grants coins to an arbitrary callerId and tags the row so it's visually distinguishable from real IAP / sandbox-IAP / real rewards. X-Admin-Key authed and is the only path the admin-panel uses. (Replaces the old user-self-grant /grantTestCoins, removed pre-launch — it was JWT-only with no admin gate.)

Goes through WalletService::grantCoins, so the same sync-first pipeline that handles every other coin movement applies (MySQL commit then RC mirror via RevenueCatClient::adjust).

Hardcoded by the server (NEVER trusted from client):

  • metadata.source = "admin"
  • metadata.subSource = "test_grant"
  • metadata.environment = "TEST"
  • reference_id = "admin_test_grant:<adminUserId>:<idempotencyKey>"
Authorizations:
AdminKey
Request Body schema: application/json
required
callerId
required
string

Target user callerid (digits only, see CLAUDE.md "callerid storage form").

amount
required
integer >= 1
note
string <= 500 characters

Free-text reason; lands in metadata, capped server-side.

idempotencyKey
required
string

UUID supplied by the admin-panel; namespaced into the reference_id.

adminUserId
required
string

Audit field — admin-panel session username. Required.

Responses

Request samples

Content type
application/json
{
  • "callerId": "string",
  • "amount": 1,
  • "note": "string",
  • "idempotencyKey": "string",
  • "adminUserId": "string"
}

Response samples

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

admin-otp

Admin OTP history — paginated otp_requests view for support / fraud triage

Paginated `otp_requests` history (admin panel)

Read-only forensic view of OTP issuance — phone, purpose, IP, attempt count, status, timestamps. Backs the admin-panel "OTP History" page. Useful for support ("OTP didn't arrive / didn't work") and fraud triage (high attempt_count, repeated phones from the same IP).

All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).

Status is derived in SQL — the table only carries is_used + expires_at:

  • usedis_used = 1
  • expiredis_used = 0 AND expires_at < NOW()
  • pendingis_used = 0 AND expires_at >= NOW()

Security: never returns otp_code_hash. Each row carries a boolean hasCode (presence) so admins can confirm a record was created without seeing the hash.

Authorizations:
AdminKey
query Parameters
phone
string
Example: phone=90555

Substring match against phone (LIKE %…%).

purpose
string
Enum: "login" "register" "verify_phone"
Example: purpose=login
status
string
Enum: "used" "pending" "expired"
Example: status=used
ipAddress
string
Example: ipAddress=203.0.113.42

Exact match on ip_address.

minAttemptCount
integer >= 0
Example: minAttemptCount=3

Floor on attempt_count — useful for spotting brute-force attempts.

dateFrom
string <date-time>
Example: dateFrom=2026-05-01T00:00:00Z

Inclusive lower bound on created_at.

dateTo
string <date-time>
Example: dateTo=2026-05-05T23:59:59Z

Exclusive upper bound on created_at.

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

Responses

Response samples

Content type
application/json
{
  • "status": "OK",
  • "items": [
    ],
  • "pagination": {
    },
  • "kpis": {
    },
  • "filters": {
    }
}

admin-revenuecat-sync

Admin monitor for the RevenueCat coin-sync pipeline (worker liveness, queue depth, recent failures)

Snapshot of the RevenueCat coin-sync pipeline

Read-only inspector for the RevenueCat coin-sync pipeline. Surfaces:

  • Worker liveness — derived from a Redis heartbeat key (revenuecat:worker:heartbeat). Status is active when the heartbeat is < 10s old, stale when 10–30s old, and down past 30s or missing.
  • Redis queue depth — main queue (revenuecat:coin_sync_queue, LIST) and retry queue (revenuecat:coin_sync_retry, ZSET keyed by retry-at epoch ms).
  • Database sync state — counts of negative-amount coin_history rows still pending, currently retrying (attempts > 0), and rows successfully synced today. Rows marked terminally undeliverable (revenuecat_reason IS NOT NULL) are excluded from these counts and surfaced separately under abandonedByReason.
  • Abandoned by reason — per-reason count of rows that won't be retried. The worker only auto-sets INVALID_DATA (local pre-RC validation failed) and MAX_ATTEMPTS (exceeded retry cap). GHOST_USER and RC_FATAL_OTHER are reserved for future manual admin classification — RC 4xx responses are deliberately left visible at revenuecat_synced=0 (no reason) because the same error can mean drift, which is exactly what the monitor exists to surface.
  • Recent failed/pending items — up to ?limit= rows from coin_history where amount < 0 AND revenuecat_synced = 0, ordered with still-in-play rows first and abandoned rows last. revenuecat_last_error is truncated to 500 characters server-side. revenuecatReason is non-null for abandoned rows.
  • Overall healthok / warn / crit rollup driven by worker status + queue depth + retry/failure counts.

All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header). Used by the admin-panel monitoring page.

Authorizations:
AdminKey
query Parameters
limit
integer [ 1 .. 200 ]
Default: 50
Example: limit=50

Page size for the items array (clamped 1..200, default 50).

offset
integer >= 0
Default: 0
Example: offset=0

Row offset for server-side pagination. Use with limit and the pagination.total returned in the response.

Responses

Response samples

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

Re-enqueue abandoned coin_history rows for RC sync

Clear revenuecat_reason, revenuecat_last_error, and revenuecat_sync_attempts on each matching row, then push the row back onto the worker's main queue. Use after fixing the underlying cause of a class of failures — for example after rotating the RevenueCat API key and restarting the long-running daemons that cached the old value.

Caller must supply EITHER an explicit ids list OR a reason filter (optionally narrowed by since and limit). The two forms are mutually exclusive at the application level — if both are present, ids wins.

The endpoint is idempotent at the row level: rows that were cleared but no longer eligible (e.g. concurrent worker resolution) are counted under skipped and not requeued. Successfully requeued ids are returned so the panel can correlate.

Authorizations:
AdminKey
Request Body schema: application/json
required
ids
Array of integers[ items >= 1 ]

Explicit coin_history.id values to clear and re-enqueue.

reason
string
Enum: "GHOST_USER" "INVALID_DATA" "RC_FATAL_OTHER" "RC_DRIFT" "MAX_ATTEMPTS"

Restrict to rows currently marked with this reason.

since
string

Only rows with created_at >= since (ISO datetime). Optional.

limit
integer [ 1 .. 500 ]
Default: 100

Cap on rows to re-enqueue per call. Default 100, max 500.

Responses

Request samples

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

Response samples

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

Reconcile a drift / fatal coin_history row against RevenueCat

For an RC_DRIFT or RC_FATAL_OTHER (or any non-NULL revenuecat_reason) row, read both sides' current balances, optionally call RC's adjust API to bring RC into lockstep with MySQL (MySQL is authoritative), write a zero-amount type='reconcile' audit row that preserves the original transaction's metadata.source, and mark the original row revenuecat_reason='RECONCILED'.

Two-phase by contract — pass dryRun:true first to preview what would change, then dryRun:false to apply.

The audit row is finance/audit's evidence trail. Its metadata carries: the original row id and reason, the operator id and their typed reason, the RC and MySQL balances at reconciliation time, the delta applied to RC, and the RC HTTP status. Filtering the coin-history panel by source returns the original transaction AND its reconciliation side-by-side — the full story is intact.

RC's virtual_currencies/transactions adjustments do not appear in RC's monetary finance reports (those only cover real-money IAP). The reconcile is invisible to finance dashboards by design.

Authorizations:
AdminKey
path Parameters
coinHistoryId
required
integer >= 1

The coin_history.id of the row to reconcile.

Request Body schema: application/json
required
operatorId
required
string

Identifier of the admin performing the reconcile. Stored on the audit row's metadata.operatorId for traceability.

operatorReason
required
string

Free-text justification (Turkish or English). Stored on the audit row's metadata.operatorReason. This is what an auditor reads to understand WHY the operator reconciled.

dryRun
boolean
Default: false

When true, the endpoint returns a preview of what would change (current MySQL/RC balances, RC delta that would be applied) without writing anything or calling RC's adjust endpoint. Use first; then re-call with dryRun:false to apply.

Responses

Request samples

Content type
application/json
Example
{
  • "operatorId": "admin@telpass",
  • "operatorReason": "RC was zeroed at launch; balances now match",
  • "dryRun": true
}

Response samples

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

admin-system-config

Admin runtime knobs (system_config) read/write

List all system_config knobs

Returns the full set of runtime-tunable knobs from system_config (e.g. otp_max_hourly_requests, daily_reset_hour_utc, wheel_daily_spin_limit_default/_vip). All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware.

Authorizations:
AdminKey

Responses

Response samples

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

Read a single knob

Authorizations:
AdminKey
path Parameters
key
required
string

Responses

Response samples

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

Update a single knob

Authorizations:
AdminKey
path Parameters
key
required
string
Request Body schema: application/json
required
required
string or integer or boolean or number

Type-coerced server-side per the knob's declared type.

Responses

Request samples

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

Response samples

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

Alias of PUT (legacy admin clients)

Same handler as PUT for clients that can only POST.

Authorizations:
AdminKey
path Parameters
key
required
string
Request Body schema: application/json
required
required
string or integer or boolean or number

Responses

Request samples

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

Response samples

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

admin-wheel

Admin wheel operations (currently — daily-spin reset)

Reset all users' daily wheel spin counters (cron-invoked)

Resets every user's daily_wheel_spin allowance to the configured tier amount. Intended to be called by a daily cron job. JWT-exempt because it runs unattended; locked down by network ACL.

No request body. Returns a summary of how many users were touched.

Responses

Response samples

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

admin-vip

Admin VIP status lookup by callerId (reads live from IVR)

Admin lookup of a user's VIP status by phone (callerId)

Returns the IVR-derived VIP status for the given callerId. Same active-VIP rule as /vip/me: uuid set AND subscriptionsEnd in the future.

Authorizations:
AdminKey
path Parameters
callerId
required
string

Phone number in callerid form (digits only; leading 00 stripped).

Responses

Response samples

Content type
application/json
{
  • "callerId": "string",
  • "data": {
    }
}

admin-voice-rooms

Ses odası moderasyonu — aktif odaları ada göre arama ve admin zorla kapatma (X-Admin-Key).

Aktif ses odalarını listele (moderasyon)

Destek / içerik moderasyonu: room_name içinde arama (q), type ile filtre (group / vip). Yanıtta roomName, ev sahibi hostCallerId ve sayaçlar döner. Kimlik doğrulama X-Admin-Key (ADMIN_API_KEY) ile.

Authorizations:
AdminKey
query Parameters
type
string
Enum: "group" "vip"
Example: type=group
q
string
Example: q=sohbet

Oda adında bölüm eşleşmesi (LIKE %q%).

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

Responses

Response samples

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

Odayı zorla kapat (ev sahibi kontrolü yok)

Aktif odayı ve ilişkili üye / koltuk verilerini siler; WebSocket istemcilerine room_closed yayınlanır. Uygunsuz oda adları için kullanılabilir; ev sahibi normal DELETE /api/voice-rooms/{roomID} ile de kapatabilir.

Authorizations:
AdminKey
path Parameters
roomID
required
string

Responses

Response samples

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

admin-users

Admin per-user state operations. FTU reset = bring this user back to first-time-user state (preserve identity + IAP audit, wipe gameplay state, mirror RC zero, IVR VIP delete). Two-phase by contract: dryRun:true first, then dryRun:false.

Reset a user back to first-time-user (FTU) state

Brings the target user back to a fresh-signup state without deleting the users row, OAuth identities, or IAP forensic trails. Mirror in spirit of database/maintenance/launch_reset_truncate.sql but scoped to a single callerid and live-safe.

Two-phase by contract. First call with dryRun:true to inspect what would be wiped. Re-call with dryRun:false to apply.

What gets wiped (live-mode):

  • Wallet: zeroed via a synthetic coin_history row tagged with reference_id="ftu_reset:{callerId}:{date}" and metadata reason="ftu_reset". coin_history itself is NOT truncated — the row is preserved for monitoring/finance reporting.
  • RC mirror: WalletService synchronously zeros RC. A defensive second pass adjusts RC if it drifted positive.
  • Allowance ledger + balances (user_allowances, user_allowance_history).
  • Reward streak (reward_claims).
  • Wheel runtime (wheel_bets, user_daily_wheel_spins, scope=daily rows in wheel_rounds).
  • Voice room runtime (voice_room_seat_timers, voice_room_daily_sits, voice_room_members).
  • Hosted voice rooms owned by this user are closed (deleted + room_closed broadcast to listeners).
  • VIP is cancelled on IVR via subscriptionsDelete. IVR failure does NOT abort the rest of the reset; surfaced in result.vip so the admin can retry manually.
  • Heart edges, friendships, friend requests, blocks, profile + interests, owned avatars, system message inbox.
  • Auth: user_sessions, user_refresh_tokens, user_devices cleared so the next login forces a full re-auth flow.
  • Counters on users (calls / swipes / hearts / last_login_at).
  • Daily-streak / attendance reward (Firestore daily_streak_users/{callerId} doc) is deleted so the re-FTU'd user starts day-0 on next claim. Best-effort: Firestore unreachable → skip rather than abort the reset.

What is preserved:

  • users row itself (callerid, uuid, user_key, display_name).
  • user_identities (OAuth bindings).
  • user_purchases, paymentHistory, revenuecat_webhook_events (IAP forensic trails).
  • coin_history (we add one synthetic row, never truncate).

Idempotency. Defaults to ftu_reset:{callerId}:{YYYY-MM-DD}. Same-day replays no-op via the wallet's reference_id unique index and RC's Idempotency-Key header. Override only if testing multi-reset-per-day flows.

Authorizations:
AdminKey
path Parameters
callerId
required
string

Target user callerid (digits-only, max 20 chars).

Request Body schema: application/json
optional
dryRun
boolean
Default: true

When true (default), no writes happen — response contains a preview block of counts. When false, the reset is applied and response contains a result block.

idempotencyKey
string

Optional. Defaults to ftu_reset:{callerId}:{YYYY-MM-DD}. Used as the namespace prefix for the wallet ledger reference and the RC Idempotency-Key header.

Responses

Request samples

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

Response samples

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

admin-system-messages

Admin → user message pool. Direct sends, listing, and force-delete. Templates / broadcasts / auto-rules ship in P1/P2 — see .claude/plans/admin-system-messages.md.

Direct admin send to one or more users

Inserts one row per userIds entry into system_messages with delivery_status='pending'. The dispatcher cron (bin/sysmsg_dispatcher.php, every minute) picks them up and fires FCM push asynchronously — the HTTP response returns the moment the rows are persisted, NOT after the push completes. Per .claude/plans/admin-system-messages.md v1 does NOT do template substitution here; pass already-rendered text.

Authorizations:
AdminKey
Request Body schema: application/json
required
userIds
Array of integers <int64> [ items <int64 > >= 1 ]

One or more users.id values to deliver the message to.

callerIds
Array of strings

Phone-style caller ids (digits only). Resolved server-side to users.id. Unknown callerids count toward skippedUnknown in the response. Most ops tooling supplies callerids; userIds are for debugging or admin-panel auto-fill flows that already resolved.

title
required
string <= 255 characters

Notification title (already-rendered text; no template substitution at this endpoint).

body
required
string

Notification body (already-rendered text).

scheduledAt
string <date-time>

ISO-8601 timestamp. If omitted, the message is sent as soon as the dispatcher cron next ticks (typically <60s). For delayed sends pass a future timestamp; the cron will pick it up only once scheduled_at <= NOW(). The message is also INVISIBLE in the user inbox until this time (visibility gate).

validUntil
string or null <date-time>

Auto-hide datetime. After this passes the message disappears from the inbox AND is suppressed from the dispatcher (status flips to suppressed). Omit / null = never expires. Must be strictly after scheduledAt or you get INVALID_VALIDITY_WINDOW.

pushEnabled
boolean
Default: true

When false, the row is created but the dispatcher immediately marks it suppressed so no FCM banner fires. The user still sees it in the in-app inbox.

object

Optional structured payload attached to the inbox row. Reserved keys: deepLink, imageUrl, cta. Surfaces back to the user via GET /api/system-messages.

idempotencyKey
string <= 96 characters

Per-request idempotency key. Namespaced internally as admin_direct:{key}:{userId} so a retry of the same request picks up exactly the rows that previously failed without re-sending to the rest.

createdBy
string <= 64 characters

Free-text label persisted in the created_by column of broadcast jobs (and reserved for direct sends in future). Useful for ops triage when many admins share ADMIN_API_KEY.

redirectRoute
string or null <= 128 characters

Opaque deep-link string echoed back to the Flutter client in the FCM data payload as redirectRoute. Caller override wins over the template default. When NULL the dispatcher emits the server-default system_inbox on the wire. Grammar lives client-side — see .claude/decisions/2026-05-12-system-message-redirect-route.md. Common values "paywall:coins_500", "avatar_shop", "coin_shop", "event:halloween_2026", "system_inbox".

Responses

Request samples

Content type
application/json
{
  • "userIds": [
    ],
  • "callerIds": [
    ],
  • "title": "Hoş geldiniz",
  • "body": "Yeni özellikleri keşfetmeye hazır mısınız?",
  • "scheduledAt": "2026-05-15T10:00:00Z",
  • "validUntil": "2019-08-24T14:15:22Z",
  • "pushEnabled": true,
  • "metadata": { },
  • "idempotencyKey": "campaign-2026-04-spring-001",
  • "createdBy": "ops-burak",
  • "redirectRoute": "paywall:coins_500"
}

Response samples

Content type
application/json
{
  • "inserted": 0,
  • "skippedDuplicates": 0,
  • "skippedUnknown": 0,
  • "messageIds": [
    ]
}

Admin pool listing (paginated, filterable)

Returns inbox rows across all users, newest first. All filters are AND-combined; omit any to disable. Use this surface to triage pending/failed rows or audit a specific user's history.

Authorizations:
AdminKey
query Parameters
userId
integer <int64>
Example: userId=1042

Filter to a single recipient (users.id).

ruleId
integer
Example: ruleId=7

Filter to messages produced by a specific auto-rule (P2; null on direct sends).

status
string
Enum: "pending" "delivered" "partial" "failed" "no_token" "suppressed"
Example: status=delivered
since
string <date-time>
Example: since=2026-05-01T00:00:00Z

Only return rows whose created_at >= since.

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

Responses

Response samples

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

Queue a broadcast to a user segment (all / vip / non_vip)

Inserts ONE row in system_message_broadcast_jobs. The fan-out cron (bin/sysmsg_broadcast_fanout.php, every minute) pages through users matching the audience, calls IVR per user for vip/non_vip filtering, and inserts per-user system_messages rows. Synchronous fan-out is forbidden — see plan invariant #4.

With an idempotencyKey, a duplicate POST returns 200 + replayed=true instead of creating a new job.

Authorizations:
AdminKey
Request Body schema: application/json
required
audience
required
string
Enum: "all" "vip" "non_vip"

v1 segments. vip and non_vip resolve via per-user IVR call during fan-out; for huge audiences a snapshot table will land later (see plan Open Question #1).

title
required
string <= 255 characters

Already-rendered notification title.

body
required
string

Already-rendered notification body.

scheduledAt
string <date-time>

Optional ISO-8601; defaults to now.

object
idempotencyKey
string <= 96 characters

If supplied, a duplicate request returns the existing job (replayed=true).

createdBy
string <= 64 characters

Responses

Request samples

Content type
application/json
{
  • "audience": "all",
  • "title": "string",
  • "body": "string",
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "metadata": { },
  • "idempotencyKey": "string",
  • "createdBy": "string"
}

Response samples

Content type
application/json
{
  • "jobId": 0,
  • "replayed": true,
  • "status": "pending"
}

Fetch a single inbox row by id

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "userId": 0,
  • "templateId": 0,
  • "ruleId": 0,
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "redirectRoute": "string",
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "deliveryStatus": "pending",
  • "deliveredAt": "2019-08-24T14:15:22Z",
  • "readAt": "2019-08-24T14:15:22Z",
  • "surfacedAt": "2019-08-24T14:15:22Z",
  • "deletedAt": "2019-08-24T14:15:22Z",
  • "idempotencyKey": "string",
  • "createdAt": "2019-08-24T14:15:22Z"
}

Admin hard-delete (force-removes the row from the user's inbox AND the admin pool)

Differs from the user-facing soft-delete: this DROPs the row entirely rather than setting deleted_at. Use sparingly — rows usually stay forever for audit. Intended for cleaning up errant sends that should not stay in user inboxes.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

Inspect the progress of a broadcast fan-out job

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "audience": "all",
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "idempotencyKey": "string",
  • "cursorUserId": 0,
  • "status": "pending",
  • "insertedCount": 0,
  • "createdBy": "string",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Create a new template

Templates use {{var}} substitution rendered at rule-fire time (auto-rules, P2). The admin panel is expected to lint placeholder keys against a known whitelist before save.

Authorizations:
AdminKey
Request Body schema: application/json
required
templateKey
required
string <= 64 characters

Stable key (e.g. ftu_welcome_10min). Unique across templates.

titleTemplate
required
string <= 255 characters

Supports {{var}} placeholders rendered at fire time.

bodyTemplate
required
string

Supports {{var}} placeholders rendered at fire time.

locale
string <= 8 characters
Default: "tr"
redirectRoute
string or null <= 128 characters

Default deep-link for every message rendered from this template. Per-instance callers (direct send, announcement create) can override; rule-fired messages inherit verbatim. Opaque string — see .claude/decisions/2026-05-12-system-message-redirect-route.md.

Responses

Request samples

Content type
application/json
{
  • "templateKey": "string",
  • "titleTemplate": "string",
  • "bodyTemplate": "string",
  • "locale": "tr",
  • "redirectRoute": "avatar_shop"
}

Response samples

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

List templates (paginated, optionally filtered by activeOnly)

Authorizations:
AdminKey
query Parameters
activeOnly
boolean
Example: activeOnly=true
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

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

Fetch a single template by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "templateKey": "string",
  • "titleTemplate": "string",
  • "bodyTemplate": "string",
  • "locale": "string",
  • "redirectRoute": "string",
  • "isActive": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Partial update of a template (PATCH-style — pass only changed fields)

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
titleTemplate
string <= 255 characters
bodyTemplate
string
locale
string <= 8 characters
isActive
boolean
redirectRoute
string or null <= 128 characters

Pass null to clear the template default.

Responses

Request samples

Content type
application/json
{
  • "titleTemplate": "string",
  • "bodyTemplate": "string",
  • "locale": "string",
  • "isActive": true,
  • "redirectRoute": "string"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "templateKey": "string",
  • "titleTemplate": "string",
  • "bodyTemplate": "string",
  • "locale": "string",
  • "redirectRoute": "string",
  • "isActive": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Soft-disable a template (sets is_active=0; row stays for audit/joins)

Hard delete is intentionally not supported. Existing system_messages.template_id rows would dangle. To "remove" a template, soft-disable it; rules referencing it stop firing.

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

Create an auto-fire rule

Rules tie a template to a trigger event + audience. The rule engine renders the template at fire time and inserts a system_messages row for each matched user. Idempotency on (rule_id, user_id, fired_period) — the engine cannot double-fire.

Authorizations:
AdminKey
Request Body schema: application/json
required
ruleKey
required
string <= 64 characters

Stable handle for ops debugging. Unique across rules.

templateId
required
integer

Reference to an active system_message_templates.id.

triggerEvent
required
string
Enum: "user_registered" "user_first_login" "user_returned_after_idle" "scheduled_recurring"
  • user_registered / user_first_login — fire once per user from the auth flow's post-commit hook. (In v1 these are the same moment; both events fire when a user authenticates for the first time.)
  • user_returned_after_idle — daily SQL match. Requires triggerParams.idleDays (1-3650).
  • scheduled_recurring — per-period fan-out. Requires triggerParams.period ∈ {daily, weekly, monthly}.
object or null

Per-event params. Required for idle/recurring; optional for inline events (kept null typically). Examples: {"idleDays":14}, {"period":"weekly"}.

object or null

{"segment":"all|vip|non_vip"}. NULL = all active users.

delaySeconds
integer >= 0
Default: 0

Applied at fire time. scheduled_at = fired_at + delaySeconds. Used to implement "10 minutes after registration" style rules.

targetKind
string
Default: "personal"
Enum: "personal" "announcement"

personal — fire produces one system_messages row per matched user (legacy behaviour, default). announcement — fire produces ONE audience-wide announcement per period (catchup + valid_until automatic). Only valid with trigger_event=scheduled_recurring; pairing with any per-user trigger returns 400 INVALID_RULE_TARGET.

Responses

Request samples

Content type
application/json
{
  • "ruleKey": "string",
  • "templateId": 0,
  • "triggerEvent": "user_registered",
  • "triggerParams": { },
  • "audienceFilter": { },
  • "delaySeconds": 0,
  • "targetKind": "personal"
}

Response samples

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

List auto-fire rules (paginated, optionally filtered by activeOnly)

Authorizations:
AdminKey
query Parameters
activeOnly
boolean
Example: activeOnly=true
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0

Responses

Response samples

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

Fetch a single rule by id

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "ruleKey": "string",
  • "templateId": 0,
  • "triggerEvent": "user_registered",
  • "triggerParams": { },
  • "audienceFilter": { },
  • "targetKind": "personal",
  • "delaySeconds": 0,
  • "isActive": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Partial update of a rule

Authorizations:
AdminKey
path Parameters
id
required
integer
Request Body schema: application/json
required
templateId
integer
triggerEvent
string
Enum: "user_registered" "user_first_login" "user_returned_after_idle" "scheduled_recurring"
object or null
object or null
delaySeconds
integer >= 0
isActive
boolean
targetKind
string
Enum: "personal" "announcement"

See create-request notes. Switching an active rule from personal to announcement while it has a non-recurring trigger returns 400 INVALID_RULE_TARGET.

Responses

Request samples

Content type
application/json
{
  • "templateId": 0,
  • "triggerEvent": "user_registered",
  • "triggerParams": { },
  • "audienceFilter": { },
  • "delaySeconds": 0,
  • "isActive": true,
  • "targetKind": "personal"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "ruleKey": "string",
  • "templateId": 0,
  • "triggerEvent": "user_registered",
  • "triggerParams": { },
  • "audienceFilter": { },
  • "targetKind": "personal",
  • "delaySeconds": 0,
  • "isActive": true,
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Soft-disable a rule (sets is_active=0)

Hard delete is intentionally not supported. Existing system_messages.rule_id rows would dangle.

Authorizations:
AdminKey
path Parameters
id
required
integer

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

List the {{var}} injection key registry

Single source of truth for the placeholder keys available to admin- authored templates. The admin panel consumes this to render chip- based variable pickers (Templates / Compose / Automations) and to warn when a template references a key that the chosen trigger does not supply.

Scopes:

  • global — always available (date, weekday, month, year).
  • user — available wherever the renderer knows a recipient.
  • trigger— only injected when a rule with that trigger fires.
Authorizations:
AdminKey

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "byScope": {
    },
  • "byTrigger": {
    },
  • "triggers": [
    ],
  • "personas": [
    ],
  • "segments": [
    ]
}

Create an announcement

Authors a broadcast definition with a validity window, audience filter, and optional FCM push toggle. Per-user inbox state is materialized lazily (catchup on inbox fetch) and eagerly (dispatcher at scheduled_at). New users registering after scheduled_at see the message on their first inbox fetch as long as valid_until has not passed.

Idempotency: pass idempotencyKey; re-submission returns the existing announcement (replayed=true) instead of duplicating.

Authorizations:
AdminKey
Request Body schema: application/json
required
audienceType
required
string
Enum: "all" "vip" "non_vip" "specific_users"

all — every active user. vip / non_vip — resolved per-user via live IVR call (fail-closed: IVR error excludes the user). specific_users — caller MUST supply recipientUserIds and/or recipientCallerIds.

title
string <= 255 characters

Push banner title. Inbox sender label is a separate [SYSTEM] constant from system_config.

body
string <= 2000 characters
scheduledAt
string <date-time>

Visibility gate. The announcement is invisible in inboxes AND no push fires until this time. Defaults to NOW() when omitted.

validUntil
string or null <date-time>

Auto-hide datetime. After this passes the announcement disappears from every inbox (recipients and never-seen users alike). Omit or send null for "never expires".

pushEnabled
boolean
Default: true

When false, the message appears in the in-app inbox but no FCM banner is fired.

object or null
recipientUserIds
Array of integers

Required when audienceType=specific_users (alongside or instead of recipientCallerIds).

recipientCallerIds
Array of strings

Digits-only callerids; resolved server-side to user ids. Unknown ids are silently dropped.

idempotencyKey
string <= 128 characters

Re-submitting with the same key returns the original announcement instead of creating a duplicate.

createdBy
string <= 64 characters

Free-text admin label for audit; defaults to admin.

templateId
integer or null <int64>

FK to system_message_templates. When set, the template's title_template / body_template override the caller-supplied title / body. Per-recipient render (incl. {{firstName}} and other user-context placeholders) happens at materialize time, not at create time. Errors: TEMPLATE_NOT_FOUND (404), INACTIVE_TEMPLATE (409).

object or null

Static context map merged into the per-recipient render context (admin/rule-supplied). User-specific keys like firstName are injected automatically — only put non-user values here (campaign name, periodKey, etc.).

redirectRoute
string or null <= 128 characters

Per-blast deep-link override echoed back to the Flutter client in the FCM data payload as redirectRoute. When omitted, inherits the template's redirect_route; when both are null, the dispatcher emits the server-default system_inbox on the wire. Opaque string — see .claude/decisions/2026-05-12-system-message-redirect-route.md.

Responses

Request samples

Content type
application/json
{
  • "audienceType": "all",
  • "title": "string",
  • "body": "string",
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "validUntil": "2019-08-24T14:15:22Z",
  • "pushEnabled": true,
  • "metadata": { },
  • "recipientUserIds": [
    ],
  • "recipientCallerIds": [
    ],
  • "idempotencyKey": "string",
  • "createdBy": "string",
  • "templateId": 0,
  • "variables": { },
  • "redirectRoute": "event:halloween_2026"
}

Response samples

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

List announcements (admin pool view)

Authorizations:
AdminKey
query Parameters
audienceType
string
Enum: "all" "vip" "non_vip" "specific_users"
pushStatus
string
Enum: "pending" "dispatching" "completed" "disabled"
since
string <date-time>

Filter to rows with created_at >= since.

activeOnly
string
Value: "1"

Pass 1 to filter to currently-visible rows (scheduled_at <= NOW() AND valid_until > NOW()).

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

Responses

Response samples

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

Get a single announcement (with push status counts and recipient list when specific_users)

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "redirect_route": "string",
  • "audience_type": "all",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "valid_until": "2019-08-24T14:15:22Z",
  • "push_enabled": 0,
  • "push_status": "pending",
  • "push_cursor_user_id": 0,
  • "idempotency_key": "string",
  • "created_by": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "push_status_counts": {
    },
  • "recipient_user_ids": [
    ]
}

Patch the two ops-editable fields (`validUntil`, `pushEnabled`)

Use this to extend a promo window mid-flight, or to kill an in-flight push (pushEnabled=false flips push_status to disabled and stops the dispatcher from claiming new state rows).

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>
Request Body schema: application/json
required
validUntil
string or null <date-time>

Send null to clear (never expires).

pushEnabled
boolean

Setting this to false also flips push_status to disabled so the eager dispatcher stops claiming new state rows for this announcement.

redirectRoute
string or null <= 128 characters

Send null to clear. Affects only pushes fired after the patch — already-dispatched rows kept their stored value.

Responses

Request samples

Content type
application/json
{
  • "validUntil": "2019-08-24T14:15:22Z",
  • "pushEnabled": true,
  • "redirectRoute": "string"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "redirect_route": "string",
  • "audience_type": "all",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "valid_until": "2019-08-24T14:15:22Z",
  • "push_enabled": 0,
  • "push_status": "pending",
  • "push_cursor_user_id": 0,
  • "idempotency_key": "string",
  • "created_by": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "push_status_counts": {
    },
  • "recipient_user_ids": [
    ]
}

Hard-delete an announcement (cascades to recipients + per-user state)

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

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

Cancel a scheduled or in-flight announcement

First-class cancellation. Stamps cancelled_at / cancelled_by for audit, sets valid_until=NOW() (hides the message from every inbox), and push_enabled=0, push_status=disabled (stops the dispatcher).

Idempotent. Re-cancelling an already-cancelled row returns the same snapshot without changing cancelled_at.

Rejected with ALREADY_COMPLETED once push_status='completed' — once the broadcast has fully fanned out and pushed, use PATCH /admin/announcements/{id} with validUntil instead to retroactively hide the inbox row.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>
Request Body schema: application/json
optional
cancelledBy
string

Identifier of the operator performing the cancel; defaults to "admin".

Responses

Request samples

Content type
application/json
{
  • "cancelledBy": "ops-berkk"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "title": "string",
  • "body": "string",
  • "metadata": { },
  • "redirect_route": "string",
  • "audience_type": "all",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "valid_until": "2019-08-24T14:15:22Z",
  • "push_enabled": 0,
  • "push_status": "pending",
  • "push_cursor_user_id": 0,
  • "idempotency_key": "string",
  • "created_by": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "push_status_counts": {
    },
  • "recipient_user_ids": [
    ]
}

Campaign-analytics funnel for one announcement

Read-only aggregation over user_announcement_state for the given announcement. Returns the recipient → surfaced → opened funnel plus push-status splits and time-to-read percentiles.

recipients counts state rows materialized so far — eagerly via the push dispatcher's Phase 1 fan-out, and lazily via the catchup service on each inbox open. For broad audiences (all / vip / non_vip) this grows over time as users open their inbox, so openRate is "of people reached so far" not "of the total addressable audience".

surfaced is the count of rows whose surfaced_at is set — i.e. the row was returned by the inbox-list endpoint at least once. opened is the count with read_at set (user explicitly tapped the message in the thread page).

p50MinutesToRead / p90MinutesToRead are percentiles of (read_at - COALESCE(surfaced_at, first_seen_at)) in whole minutes, computed only over rows where read_at IS NOT NULL. Null when no opens have happened yet.

Authorizations:
AdminKey
path Parameters
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "announcementId": 0,
  • "audienceType": "all",
  • "scheduledAt": "2019-08-24T14:15:22Z",
  • "validUntil": "2019-08-24T14:15:22Z",
  • "pushEnabled": true,
  • "pushStatus": "pending",
  • "cancelledAt": "2019-08-24T14:15:22Z",
  • "funnel": {
    }
}

system-messages

User-facing inbox for admin/system messages. Rendered pinned at the top of the messaging screen, separate from Zego DMs. Soft-delete is per-user; admin force-delete is a separate hard delete.

User inbox — chronological (oldest first), unified across personal + announcement kinds

Returns the calling user's inbox rows. The Flutter messaging screen renders these pinned at the top, separate from Zego DMs. Soft-deleted rows (DELETE /api/system-messages/{kind}/{id}) are filtered out.

Each row carries a kind discriminator: personal (per-user system_messages rows) or announcement (broadcast definitions with lazy per-user state — new users register-after-send still catch up to currently-valid announcements on first inbox fetch).

Order defaults to ASC by scheduledAt (target visibility time, not row creation time). Pass direction=desc to flip.

Authorizations:
BearerAuth
query Parameters
limit
integer [ 1 .. 200 ]
Default: 30
Example: limit=30
offset
integer >= 0
Default: 0
Example: offset=0
direction
string
Default: "asc"
Enum: "asc" "desc"

asc (oldest first, default) — chronological conversation view. desc — feed-style.

Responses

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "limit": 1,
  • "offset": 0,
  • "direction": "asc"
}

Single collapsed preview of the user's SYSTEM messagebox

Returns one row representing the user's entire SYSTEM messagebox — live inbox title (resolved from system_config, NOT frozen onto the row), live unread count, and the newest deliverable inbox row's body

  • timestamp + id. Drives the "ONE SYSTEM messagebox per user" invariant on the messages-list screen: the client renders a single tile from this response instead of paging the inbox and rendering N tiles.

hasMessages: false means the user has zero deliverable rows (empty inbox, or every row soft-deleted / suppressed / permanent failed); the client should hide the SYSTEM tile entirely.

Authorizations:
BearerAuth

Responses

Response samples

Content type
application/json
{
  • "title": "[SYSTEM]",
  • "unreadCount": 0,
  • "hasMessages": true,
  • "lastMessage": "string",
  • "lastAt": "2019-08-24T14:15:22Z",
  • "lastId": 0
}

Count of un-read, non-deleted inbox rows

Drives the messaging-tab badge.

Authorizations:
BearerAuth

Responses

Response samples

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

Mark a single inbox row read

Idempotent — re-marking an already-read row is a no-op.

kind is one of personal (system_messages row) or announcement (user_announcement_state row keyed on the broadcast definition). IDs are namespaced per kind.

Authorizations:
BearerAuth
path Parameters
kind
required
string
Enum: "personal" "announcement"
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}

Soft-delete a single inbox row (hidden from user; kept for admin audit)

kind is one of personal or announcement — see markRead.

Authorizations:
BearerAuth
path Parameters
kind
required
string
Enum: "personal" "announcement"
id
required
integer <int64>

Responses

Response samples

Content type
application/json
{
  • "deleted": true,
  • "read": true,
  • "disabled": true
}