Download OpenAPI specification:
Shuffly mobile app backend API.
Covers new-format controllers only (friends, voice rooms, auth, wallet,
rewards, allowances, and the admin surface).
The WebSocket protocol (see top navbar) drives voice-room presence and
per-seat timers. The Game-Server API (also in the top navbar) is a
server-to-server surface for sibling game backends — wallet balance,
entry-fee debits, and settle/payout (separate page, separate
X-Game-Server-Key auth).
Send the refresh token in the JSON body as { "refreshToken": "..." }.
For clients that prefer the header convention, Authorization: Bearer <refreshToken>
is accepted as a fallback. The body takes precedence when both are present —
this prevents auto-attached JWT Bearer headers (common in mobile HTTP clients)
from being mistakenly treated as a refresh credential.
| refreshToken required | string The refresh token from the last token pair. |
{- "refreshToken": "string"
}{- "status": "OK",
- "message": "Token yenilendi.",
- "data": {
- "jwtToken": "string",
- "refreshToken": "string",
- "expiresAtMs": 1776499200000,
- "refreshExpiresAtMs": 1779091200000,
- "expiresAt": "2026-04-18T12:00:00Z",
- "refreshExpiresAt": "2026-05-11T12:00:00Z"
}
}Send the refresh token in the JSON body as { "refreshToken": "..." }.
For clients that prefer the header convention, Authorization: Bearer <refreshToken>
is accepted as a fallback. The body takes precedence when both are present.
| refreshToken required | string The refresh token from the last token pair. |
{- "refreshToken": "string"
}{- "status": "OK",
- "message": "Çıkış yapıldı.",
- "data": {
- "ok": true
}
}Mobile sign-in via a Google ID token obtained by the client through the
google_sign_in Flutter package. The server verifies the token's
signature against Google's JWKS, asserts iss and aud, and on first
sign-in creates a users row with a synthetic +999... callerid plus
matching user_identities and user_profiles rows. Returns the same
token bundle as the OTP login path so downstream code is identical.
Public — no JWT required (this IS the login).
| idToken required | string Google ID token (JWT) issued by the |
| displayName | string or null Client-supplied hint, used as |
| deviceId | string Stable client-side device identifier (e.g. a UUID persisted in
local storage). Used as the |
| deviceType | string Free-form (e.g. |
| firebaseToken | string FCM registration token from
|
| versionNo | string App version string for triage (e.g. |
| appVersion | string Alias for |
{- "idToken": "string",
- "displayName": "string",
- "deviceId": "string",
- "deviceType": "string",
- "firebaseToken": "string",
- "versionNo": "string",
- "appVersion": "string"
}{- "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": {
- "email": "string",
- "emailVerified": true,
- "displayName": "string",
- "picture": "string",
- "isPrivateRelay": true
}
}Mobile sign-in via an Apple identity token obtained by the client
through the sign_in_with_apple Flutter package. The server verifies
the token against Apple's JWKS, asserts iss=https://appleid.apple.com
and the configured bundle ID as aud, then creates or returns a user
as with Google. Apple emits the user's name only on the very first
sign-in, so the client may pass givenName/familyName as a fallback
for display_name. The user's email may be a private-relay address;
this is reported via profile.isPrivateRelay.
Public — no JWT required (this IS the login).
| identityToken required | string Apple identity token (JWT) issued by the |
| authorizationCode | string or null Optional Apple authorization code. Reserved for a follow-up that exchanges it for a refresh token (account-deletion / revoke flow). Currently accepted but not used. |
| givenName | string or null Apple sends names only on the very first sign-in; clients should cache and pass them here so the profile can be populated. |
| familyName | string or null See |
| deviceId | string Stable client-side device identifier (e.g. a UUID persisted in
local storage). Used as the |
| deviceType | string Free-form (e.g. |
| firebaseToken | string FCM registration token from
|
| versionNo | string App version string for triage (e.g. |
| appVersion | string Alias for |
{- "identityToken": "string",
- "authorizationCode": "string",
- "givenName": "string",
- "familyName": "string",
- "deviceId": "string",
- "deviceType": "string",
- "firebaseToken": "string",
- "versionNo": "string",
- "appVersion": "string"
}{- "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": {
- "email": "string",
- "emailVerified": true,
- "displayName": "string",
- "picture": "string",
- "isPrivateRelay": true
}
}Generates a 6-digit code, hashes and stores it in otp_requests, then
sends it via SMS (vendor-specific routing for 90* numbers). Rate-limited:
one send per phone per otp_resend_cooldown_seconds (system_config knob,
default 60), and at most otp_max_hourly_requests per hour (default 10).
A Memcached lock with the same TTL as the resend cooldown prevents
duplicate sends from the same phone.
Public route — no JWT required. The same endpoint is the canonical "resend" surface — call it again once the cooldown has elapsed.
Successful responses include resendAvailableAtMs (epoch ms) — the
earliest time at which a subsequent call to this endpoint will be
accepted. Clients should use it to gate a "Resend code" button. The
expiresAt field is the OTP TTL (configurable via otp_ttl_minutes,
default 5 minutes).
When debug_otp_return=true (system_config), the response also includes
the plaintext OTP under debugOtp — never enable in production.
| telefon required | string Phone number, E.164-style without |
| phone | string Alias for |
{- "telefon": "string",
- "phone": "string"
}{- "status": "OK",
- "message": "OTP gönderildi.",
- "expiresAt": "2026-04-30 12:05:00",
- "resendAvailableAtMs": 1761820860000,
- "debugOtp": "string",
- "serviceResponse": "string"
}Validates the user-supplied OTP against the most recent unused
otp_requests row for the phone. On success: creates the user if new,
upserts user_identities/user_profiles, registers/refreshes
user_devices if deviceId is supplied, mints JWT + refresh tokens
via AuthTokenService, and writes a user_sessions row.
Public route — no JWT required (this IS the login).
/otpCheck is an alias for the same handler, kept for legacy clients.
| telefon required | string |
| phone | string Alias for |
| otp required | string 6-digit code. |
| kod | string Alias for |
| deviceId | string |
| deviceType | string Free-form (e.g. |
| firebaseToken | string |
| versionNo | string |
| appVersion | string Alias for |
{- "telefon": "string",
- "phone": "string",
- "otp": "string",
- "kod": "string",
- "deviceId": "string",
- "deviceType": "string",
- "firebaseToken": "string",
- "versionNo": "string",
- "appVersion": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Same handler as /otpSmsCheck. Legacy alias.
| telefon required | string |
| phone | string Alias for |
| otp required | string 6-digit code. |
| kod | string Alias for |
| deviceId | string |
| deviceType | string Free-form (e.g. |
| firebaseToken | string |
| versionNo | string |
| appVersion | string Alias for |
{- "telefon": "string",
- "phone": "string",
- "otp": "string",
- "kod": "string",
- "deviceId": "string",
- "deviceType": "string",
- "firebaseToken": "string",
- "versionNo": "string",
- "appVersion": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| userId required | integer |
| callerid | string |
| status | string |
| is_deleted | integer Enum: 0 1 |
| last_login_at | string |
| username | string |
| full_name | string |
| gender | string Enum: "male" "female" "other" |
| second_language | string |
| birth_date | string YYYY-MM-DD. |
| avatar_id | integer Ownership-gated — must be owned by the caller. |
| avatarId | integer Alias. |
| bio | string |
| rating | number |
| property name* additional property | any |
{- "userId": 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
}{- "status": "OK",
- "data": {
- "userId": 0,
- "userKey": "string",
- "uuid": "string",
- "callerId": "string",
- "displayName": "string",
- "fullName": "string",
- "username": "string",
- "gender": "male",
- "secondLanguage": "string",
- "birthDate": "string",
- "avatarId": 0,
- "avatar_url": "string",
- "avatarUrl": "string",
- "profileAvatarUrl": "string",
- "profileImageUrl": "string",
- "profileImageStatus": "approved",
- "bio": "string",
- "rating": 0
}
}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.
| userId required | integer |
| full_name | string |
| fullName | string Alias. |
| username | string |
| bio | string <= 500 characters |
| about | string Alias for |
| 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. |
{- "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
}{- "status": "OK",
- "message": "string",
- "data": {
- "userId": 0,
- "userKey": "string",
- "uuid": "string",
- "callerId": "string",
- "displayName": "string",
- "fullName": "string",
- "username": "string",
- "gender": "male",
- "secondLanguage": "string",
- "birthDate": "string",
- "avatarId": 0,
- "avatar_url": "string",
- "avatarUrl": "string",
- "profileAvatarUrl": "string",
- "profileImageUrl": "string",
- "profileImageStatus": "approved",
- "bio": "string",
- "rating": 0
}
}Sets users.status='deleted', is_deleted=1. Idempotent: a second
call returns OK with "Hesap zaten silinmiş". Either userId or
callerid is required.
| userId | integer |
| callerid | string |
| telefon | string Alias for |
| phone | string Alias for |
{- "userId": 0,
- "callerid": "string",
- "telefon": "string",
- "phone": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Returns { countryCode, dialCode } from a hardcoded ISO-2 → dial-code
map. Unknown codes default to +90.
| countryCode | string ISO-2 (e.g. |
| country_code | string Alias. |
{- "countryCode": "string",
- "country_code": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| 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 |
{- "userId": "string",
- "user_id": "string",
- "userIds": [
- "string"
], - "user_ids": null,
- "pool": true,
- "forPool": true,
- "limit": 12
}{- "success": true,
- "message": "string",
- "data": {
- "data": [
- {
- "userId": 0,
- "userKey": "string",
- "uuid": "string",
- "fullName": "string",
- "username": "string",
- "displayName": "string",
- "bio": "string",
- "birthDate": "string",
- "age": 0,
- "gender": "string",
- "secondLanguage": "string",
- "profileAvatarUrl": "string",
- "profileImageUrl": "string",
- "rate": 0,
- "interests": [
- {
- "id": 0,
- "icon": "string",
- "iconUrl": "string",
- "name": "string"
}
], - "vip": {
- "isVip": true,
- "tier": "string",
- "expiresAtMs": 0,
- "source": "live",
- "stale": true
}
}
]
}
}Returns user + profile + interests in one call. Identifier: either
callerId (or callerid) or userId (users.id). Computes age
from birth_date. avatarUrl / profileAvatarUrl are derived via
JOIN from user_profiles.avatar_id → avatars.image_url — the
legacy free-text columns are gone. Both bio/about and
profileAvatarUrl/avatarUrl are aliased in the response for
client-version compatibility.
Uses the new error envelope ({ error: { code, message, details } })
unlike most legacy endpoints — see USER_IDENTIFIER_REQUIRED /
USER_NOT_FOUND / GET_USER_PROFILE_FAILED.
| callerId | string |
| callerid | string Alias. |
| userId | integer |
| user_id | integer Alias. |
{- "callerId": "string",
- "callerid": "string",
- "userId": 0,
- "user_id": 0
}{- "status": "OK",
- "data": {
- "userId": 0,
- "userKey": "string",
- "uuid": "string",
- "callerId": "string",
- "status": "string",
- "displayName": "string",
- "fullName": "string",
- "username": "string",
- "name": "string",
- "gender": "string",
- "secondLanguage": "string",
- "birthDate": "string",
- "age": 0,
- "avatarId": 0,
- "profileAvatarUrl": "string",
- "avatarUrl": "string",
- "profileImageUrl": "string",
- "bio": "string",
- "about": "string",
- "rating": 0,
- "interests": [
- {
- "id": 0,
- "icon": "string",
- "iconUrl": "string",
- "name": "string"
}
], - "createdAt": "string"
}
}Returns the public profile shown on a user's profile page: names,
viewer's private alias for the target, avatar, rating, age, bio,
interests, plus viewer-relative isFriend (bidirectional friendship
edge) and isSelf flags. JWT-protected; callerId is the target
user's users.callerid (digits-only, verbatim — no
re-normalization). The viewer is resolved from the JWT.
| callerId required | string Target user |
{- "userId": 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": [
- {
- "id": 0,
- "name": "string",
- "icon": "string",
- "iconUrl": "string"
}
], - "isFriend": true,
- "isSelf": true,
- "vip": {
- "isVip": true,
- "tier": "string",
- "expiresAtMs": 0,
- "source": "live",
- "stale": true
}
}Returns active rows from interests, sorted by sort_order, id.
name is localized via interest_localized_info: requested locale →
en → interests.name (legacy canonical column). Supplying neither
?locale= nor Accept-Language yields English — wire shape is
unchanged, so pre-localization clients keep working.
| locale | string Enum: "tr" "en" Example: locale=tr Overrides |
| Accept-Language | string Example: tr First segment is parsed; region tags ( |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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 } }).
| callerId required | string |
| gender required | string Enum: "male" "female" "other" |
| secondLanguage required | string |
| interests required | Array of integers non-empty |
{- "callerId": "string",
- "gender": "male",
- "secondLanguage": "string",
- "interests": [
- 0
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}name is localized the same way as /get-interests
(requested locale → en → interests.name).
| callerId required | string Example: callerId=905551112233 |
| locale | string Enum: "tr" "en" Example: locale=tr Overrides |
| Accept-Language | string Example: tr |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| callerId required | string Example: callerId=905551112233 |
| interests required | Array of integers non-empty |
{- "interests": [
- 0
]
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}callerId in query, secondLanguage in body.
| callerId required | string Example: callerId=905551112233 |
| secondLanguage required | string |
{- "secondLanguage": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Purchases, RevenueCat webhook, package catalog, IVR-recovery, and
server-initiated coin spends. Coin economy SSOT lives in
.claude/plans/wallet-system.md.
Returns rows from user_purchases for the supplied userId. Used by
the mobile client's purchase-history screen.
| userId required | integer |
{- "userId": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Inserts a row into paymentHistory. Whitelisted body fields:
salesChannel, phoneNumber, productCode, transactionDate, purchaseID, ipAdress, durationMinutes, status. Used by the legacy phone/IVR
billing flow — RevenueCat purchases use /revenueCatPaymentWebhook.
| salesChannel | string |
| phoneNumber | string |
| productCode | string |
| transactionDate | string |
| purchaseID | string |
| ipAdress | string |
| durationMinutes | integer |
| status | string |
| property name* additional property | any |
{- "salesChannel": "string",
- "phoneNumber": "string",
- "productCode": "string",
- "transactionDate": "string",
- "purchaseID": "string",
- "ipAdress": "string",
- "durationMinutes": 0,
- "status": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Public endpoint called by RevenueCat. Payload shape per RC docs.
Behavior:
event.id against
revenuecat_webhook_events (idempotent on retries).VIRTUAL_CURRENCY_TRANSACTION (or coin_* product): credits
coins via wallet_transactions + user_wallet_summary. Hardcoded
coin map: coin_1=10000, coin_2=70000, coin_3=150000.vippackage1=1, vippackage2=7, vippackage3=30):
inserts user_purchases for the audit ledger, then pushes the
subscription to IVR via POST /ivr/subscriptionsAdd. To
preserve renewal-while-active stacking, the handler first
queries IVR for the user's current expiry and offsets the new
subscription's subscriptionsStart accordingly. There is no
local VIP table — IVR is authoritative.EXPIRATION, BILLING_ISSUE,
SUBSCRIPTION_PAUSED, UNCANCELLATION, CANCELLATION) are
observability-only — logged and acked, no DB writes. IVR
independently expires its own subscriptions based on the
durationMinutes it received at subscriptionsAdd.$RCAnonymousID:*) and unknown product events are
marked processed and ignored.No JWT — public route. Future hardening: signature verification via
REVENUECAT_WEBHOOK_SECRET (planned in wallet-system.md).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Returns the static packages_map from config/payment_packs.php.
Mobile client uses this for the shop / paywall display alongside
the live price catalog from RevenueCat.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Renders an HTML page with monthly counts of distinct paying phones,
broken down by salesChannel (Google vs Apple). Accepts year and
month either as query params (GET) or form body (POST). Returns
text/html — not JSON.
No JWT in current implementation; treat as internal-only.
| year | string Example: year=2026 4-digit year (defaults to current). |
| month | string Example: month=5 1- or 2-digit month (defaults to current). |
Reads up to 10 paymentHistory rows with status='Pbx System ERROR'
from the last hour and retryCount < 3, replays them against the
configured IVR subscriptionsAdd endpoint, then writes back the
outcome (OK-LastError on success, retryCount++ and
lastRetryError on failure). Returns success/fail counts.
Operationally invoked from a scheduled job — no auth in current implementation; lock down at the network layer.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
| userId required | integer Internal users.id (numeric). |
| amount required | integer >= 1 |
| reason required | string Free-form intent label ( |
| targetUserId | integer or null Optional gift recipient ( |
{- "userId": 0,
- "amount": 1,
- "reason": "string",
- "targetUserId": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}VIP status. As of 2026-04-29 every endpoint reads live from the
IVR service (${service_base_url}/ivr/...), the cross-channel
system of record. There is intentionally no local VIP cache table.
GET /vip/me is the canonical read. /checkVip, /vipQuery,
/subscriptionsGet, /deleteVip are kept for Flutter
backwards-compat and now route through the same IVR client.
Legacy passthrough that POSTs { callerId: <phone> } to the
upstream IVR subscriptionsGet endpoint and returns the response
verbatim. The response shape is IVR's, not ours — see
/subscriptionsGet for the full field list.
Kept for Flutter diagnostic-screen backwards-compat. New callers
should use GET /vip/me (JWT-self) — that endpoint returns a
normalized envelope and correctly handles the IVR quirk where
uuid stays populated after the subscription expires.
| phone required | string Phone number; non-digits are stripped server-side. |
{- "phone": "string"
}{ }Resolves the user's active subscription on IVR (POST
/ivr/subscriptionsGet), then cancels it (POST
/ivr/subscriptionsDelete with the resolved uuid). Returns
{ status: "OK" } on successful cancellation.
Pre-2026-04-29 this endpoint wrote to a now-defunct local
vipUsers table — a silent no-op. The current implementation
actually performs the cancellation against IVR.
| userId required | string Caller-id-format phone (digits only; non-digits
stripped server-side). Field name is legacy — accepts
|
{- "userId": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Returns { vip: true|false|null, vipStatus, source, stale }
based on whether the caller has an active subscription on IVR.
Active means BOTH the IVR record's uuid is set AND
subscriptionsEnd is in the future — IVR retains the latest
subscription's uuid even after it expires, so uuid-presence
alone is not a reliable signal.
Routes through VipSummaryService (Redis read-through cache
fronting IVR). On IVR outage the last cached envelope is served
(source: 'cache', stale: true). On IVR outage AND cache
miss, vip is null and vipStatus is 'unknown' — clients
MUST treat null as 'preserve prior state', NOT as false.
Pre-2026-04-29 this returned a raw vipUsers row and used
uuid-presence as the active marker (incorrectly returning
vip: true for expired records). Pre-2026-05-11 it surfaced
IVR transport errors as 503 which the Flutter client silently
treated as vip: false, causing intermittent crown-swing for
actual VIP users. Both behaviors are now fixed.
| phone | string Phone number. |
| callerid | string |
{- "phone": "string",
- "callerid": "string"
}{- "vip": true,
- "vipStatus": "active",
- "source": "live",
- "stale": true
}Passthrough that POSTs { callerId } to
${service_base_url}/ivr/subscriptionsGet and returns IVR's
response verbatim. Used by the legacy IVR/PBX billing flow and
by Flutter's VIP diagnostic screen.
IVR response fields (all strings, present even when empty):
uuid, callerId, productCode, subscriptionsStart,
subscriptionsEnd, subscriptionsFlag, plus a few telephony
fields (genderTalk, genderDetected, malecount,
femalecount) that are not relevant to subscription state.
Quirk: uuid remains populated after subscriptionsEnd
passes — it's the uuid of the most-recent subscription record,
not an active-marker. Use both uuid non-empty AND
subscriptionsEnd > now to determine active VIP. GET /vip/me
does this for you.
| callerid | string Phone number. |
| telefon | string |
| phone | string |
{- "callerid": "string",
- "telefon": "string",
- "phone": "string"
}{ }Returns the JWT user's current VIP entitlement via the
VipSummaryService Redis read-through cache fronting IVR (single
source of truth across telephony, mobile app, and web).
Active-VIP rule: the IVR record has a non-empty uuid AND
subscriptionsEnd is strictly in the future. The legacy
uuid-only check is unsafe (IVR retains the uuid after expiry).
Outcomes:
live — IVR responded; envelope is fresh. 200.cache — IVR was unreachable; envelope served from Redis
(stale: true, within TTL min(60s, time-to-expiry)). 200.unknown — IVR unreachable AND no cached envelope. 503
with VIP_SERVICE_UNAVAILABLE; client should retry. Do NOT
collapse this to isVip: false.{- "data": {
- "isVip": true,
- "status": "active",
- "source": "live",
- "stale": true,
- "tier": "vip",
- "productId": "string",
- "startedAtMs": 0,
- "expiresAtMs": 0
}
}First-time user onboarding. POST /ftu/trial/start activates
the lifetime-once 30-minute free VIP trial that follows the
welcome-wheel flow. POST /ftu/paywall/dismissed queues a 24-hour
inbox offer when the user dismisses an FTU paywall without
purchasing. Server-side writes use the same IVR subscription path
the RevenueCat webhook uses; idempotent via
user_ftu_trials.UNIQUE(user_id, trial_type) and
system_messages.idempotency_key.
Activates the lifetime-once 30-minute free VIP trial for the authenticated user. Triggered by the client when the user presses the "Sohbete Başla" CTA on the first-time-user onboarding overlay after spinning the welcome wheel.
Side effects (all best-effort after the IVR write commits; failures here do NOT fail the trial activation):
subscriptionsAdd with productCode=206,
durationMinutes=30 — the canonical VIP write path,
shared with the RevenueCat webhook.system_messages entry with title "VIP başladı",
24-hour validity, push enabled.AllowanceService::grantVipUpgradeBonus — lifts the user's
daily item allowances to VIP-tier amounts. Idempotent on
ftu_trial:{userId}.Idempotency. Every successful and idempotent branch
returns HTTP 200 — the boolean flags in data (trialActivated,
alreadyActivated, alreadyVip, skippedTrial) carry the
outcome. Double-tap, retry-after-network-error, and concurrent
requests all converge on the same user_ftu_trials row via
a UNIQUE(user_id, trial_type) constraint.
Already-VIP behaviour. If the user already has an active
paid VIP at trial time, the trial is NOT granted on top — the
lifetime slot is marked skipped_paid_vip so the user cannot
cycle back for a free trial after the paid sub ends.
Client contract. After a 200 response the client should
push VipPostPurchaseFlowPage, which polls GET /vip/me for
the actual VIP transition (max ~80 attempts × 1.5s). The
client should NOT inspect the body of this response beyond
knowing the call succeeded; 503 means "IVR transient; retry".
{- "data": {
- "trialActivated": true,
- "alreadyActivated": true,
- "alreadyVip": true,
- "skippedTrial": true,
- "productId": "ftu_trial_30m",
- "expiresAtMs": 0,
- "durationMinutes": 0,
- "coinGrant": {
- "status": "granted",
- "granted": true,
- "amount": 10000,
- "balanceAfter": 0,
- "grantedAtMs": 0
}
}
}Called by the client when the user closes an FTU paywall screen
WITHOUT completing a purchase. The backend inserts a 24-hour
system_messages inbox entry (type paywall_offer) so the user
can return to the offer from their inbox.
Eligible paywalls: ftu_package1 (main — VIP + 15 000 coin)
and ftu_package2 (downsell — 12 500 coin).
Pre-purchase guard. If the user has already purchased the
product (user_purchases or wallet_transactions credit record
exists), no inbox row is created and the response carries
inboxed: false, reason: "ALREADY_PURCHASED".
Idempotency. Re-calling this endpoint for the same
paywallId is safe. The service uses a stable
idempotency_key = ftu_paywall_inbox:{userId}:{paywallId} so the
unique index on system_messages absorbs duplicate calls — the
24-hour validity window is set at first insertion and is NOT
extended on re-calls.
Suppression on purchase. When the user later completes a
purchase via the in-app paywall or from the inbox, the RC webhook
fires suppressOnPurchase, which soft-deletes the inbox row.
The next inbox fetch will not include the offer.
Client usage (fire-and-forget). The client should call this endpoint in the background when the paywall is dismissed without a purchase. Do NOT block the UI on the response; log errors only.
Inbox metadata shape (returned by GET /inbox)
when metadata.type == "paywall_offer":
{
"type": "paywall_offer",
"paywallId": "ftu_package1",
"productId": "ftu_package1",
"cta": {
"label": "Teklifi Gör",
"deepLink": "shuffly://paywall/ftu_package1"
}
}
| paywallId required | string Enum: "ftu_package1" "ftu_package2" RC product id of the paywall that was dismissed.
|
{- "paywallId": "ftu_package1"
}{- "data": {
- "inboxed": true,
- "reason": "ALREADY_PURCHASED",
- "expiresAtMs": 0
}
}Returns the caller's FTU 10 000-coin welcome-bonus status.
Use this as a fallback if the coinGrant field from
POST /ftu/trial/start was not received (network error,
force-quit, background kill, etc.).
Status values
| status | Meaning |
|---|---|
eligible |
Row exists; coin not yet granted. Trigger POST /ftu/trial/start. |
granted |
Coin was disbursed; grantedAtMs is set. |
not_eligible |
No row (user registered before the feature, or FTU reset without re-arm). Grant will never happen. |
Eligibility is established at the moment the user's auth
response carries isNewUser=true (first registration or admin
FTU reset → next login). Old users without a row are
permanently not_eligible.
{- "data": {
- "status": "eligible",
- "amount": 10000,
- "grantedAtMs": 0
}
}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).
| page | integer >= 1 Default: 1 Example: page=1 1-indexed page number. |
| limit | integer [ 1 .. 100 ] Default: 20 Example: limit=20 Rows per page. |
| type | string Default: "all" Enum: "all" "purchase" "spend" "refund" "grant" "migration" Example: type=spend Filter by |
{- "status": "OK",
- "data": {
- "transactions": [
- {
- "id": 0,
- "type": "purchase",
- "amount": 0,
- "balance_after": 0,
- "reference_id": "string",
- "action_type": "string",
- "resource_type": "string",
- "resource_id": "string",
- "price_label": "string",
- "metadata": null,
- "created_at": "string"
}
], - "pagination": {
- "current_page": 0,
- "per_page": 0,
- "total": 0,
- "total_pages": 0
}
}
}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).1..effectiveCap that lack a row
in daily_streak_claims for (user_id, cycle_id, day, type)
are claimed; coin amounts are credited via
WalletService::grantCoins with an idempotent reference_id
(daily_streak:<cycleId>:days_<...>).vipMinutes rewards are recorded but not credited (feature
postponed); vipAdded and newVipMinutes always return 0.DATEDIFF(today, cycleStartDate) >= cycleLength,
cycleStartDate advances by
floor(diff/cycleLength) * cycleLength days (preserves weekday)
and a new cycleId is generated. uninterrupted_streak
increments only when the just-ending cycle was fully claimed
AND the user returned within the next cycle window.| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
| upToDay required | integer >= 1 Upper bound (inclusive) of the day index to claim. The server
clamps to |
{- "upToDay": 4
}{- "coinAdded": 60,
- "vipAdded": 0,
- "claimedDays": [
- 1,
- 2,
- 3
], - "cycleId": "1747570800-3f9a2c14",
- "dayIndex": 3,
- "newCoinBalance": 5420,
- "newVipMinutes": 0
}Returns the user's current streak cycle, 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 cyclefalse = eligible-but-unclaimed todaynull = future day in this cycle, not yet eligible| callerid required | string^\d{8,15}$ Example: callerid=905344546002 Phone-based identifier matching |
{- "cycleId": "1747570800-3f9a2c14",
- "cycleLength": 7,
- "anchorDate": "2026-04-09",
- "cycleStartDate": "2026-05-14",
- "dayIndex": 5,
- "uninterruptedStreak": 3,
- "rewards": [
- {
- "day": 1,
- "type": "coin",
- "amount": 10,
- "claimed": true
}, - {
- "day": 2,
- "type": "coin",
- "amount": 20,
- "claimed": true
}, - {
- "day": 3,
- "type": "coin",
- "amount": 30,
- "claimed": false
}, - {
- "day": 4,
- "type": "coin",
- "amount": 40,
- "claimed": false
}, - {
- "day": 5,
- "type": "coin",
- "amount": 80,
- "claimed": false
}, - {
- "day": 6,
- "type": "coin",
- "amount": 120,
- "claimed": null
}, - {
- "day": 7,
- "type": "coin",
- "amount": 250,
- "claimed": null
}
]
}| targetUserId required | string User key of the target user. |
| message | string Optional message to include with the request. |
{- "targetUserId": "string",
- "message": "string"
}{- "requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
- "dbId": 0,
- "targetUserId": "string",
- "status": "pending",
- "createdAt": 0
}| direction | string Default: "incoming" Enum: "incoming" "outgoing" Example: direction=incoming |
| status | string Default: "pending" Example: status=pending |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
- "fromUser": {
- "userId": "string",
- "displayName": "string",
- "avatarUrl": "string",
- "profileImageUrl": "string"
}, - "toUser": {
- "userId": "string",
- "displayName": "string",
- "avatarUrl": "string",
- "profileImageUrl": "string"
}, - "message": "string",
- "status": "pending",
- "direction": "incoming",
- "createdAt": 0
}
], - "nextCursor": "string",
- "hasMore": true
}| fromUserId required | string |
| alias | string Optional private nickname for the new friend. Persisted via the user-alias system (see /v1/aliases) — block-checked and length-limited (1–64 chars). Failure to persist the alias does NOT roll back the friendship; the alias is best-effort. |
object Optional key-value attributes for the friendship. |
{- "alias": "string",
- "attributes": { }
}{- "ok": true,
- "friendshipCreatedAt": 0
}| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "userId": "string",
- "displayName": "string",
- "avatarUrl": "string",
- "profileImageUrl": "string",
- "friendSince": 0,
- "alias": "string",
- "attributes": { }
}
], - "nextCursor": "string",
- "hasMore": true
}| targetUserId required | string |
| message | string |
{- "targetUserId": "string",
- "message": "string"
}{- "ok": true,
- "targetUserId": "string",
- "message": "string"
}Creates block rows from the caller to each userIds[] entry.
Silently skips: empty strings, the caller's own ID, and IDs that
don't resolve to a real user.
Friendship-aware: if the caller was already friends with the
target (either via a friendships edge or an accepted
friend_requests row), user_blocks.had_friendship is set to 1
so a future DELETE /v1/blocks/{id} can auto-restore the
friendship. Pending friend requests between the two users are
cancelled as part of the same transaction.
The response's blockedUserIds is the subset actually blocked on
this call (i.e. the invalid/skipped IDs are filtered out).
| userIds required | Array of strings List of user keys to block. |
{- "userIds": [
- "string"
]
}{- "ok": true,
- "blockedUserIds": [
- "string"
]
}| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "userId": "string",
- "displayName": "string",
- "avatarUrl": "string",
- "profileImageUrl": "string",
- "createdAt": 0
}
], - "nextCursor": "string",
- "hasMore": true
}Removes the block between the caller and blockedUserId. If the
caller had blocked an existing friend (tracked via the
user_blocks.had_friendship flag set at block-time), the
friendship edges are recreated in both directions on unblock —
no second friend-request flow is required. If no prior friendship
existed, unblock only clears the block row.
This endpoint is also resilient against edge cases where exactly
one direction of the friendship edge survives (e.g. from partial
writes or older data) — it calls ensureBidirectionalFriendshipEdges
to fix up asymmetric state.
Returns 204 No Content whether or not a block row existed (the
call is idempotent).
| blockedUserId required | string |
{- "error": {
- "code": "ROOM_NOT_FOUND",
- "message": "Oda bulunamadı.",
- "details": { }
}
}| userIds required | Array of strings List of user keys to check relation with. |
{- "userIds": [
- "string"
]
}{- "relations": [
- {
- "userId": "string",
- "relation": "friend"
}
]
}Returns every alias the JWT subject has set for any other user. Only the creator can read their own aliases through this surface. Aliases are not exposed to the targets they point at.
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "creatorUserId": "u_8f3c1a",
- "targetUserId": "u_2bc09f",
- "alias": "Aşkım",
- "createdAt": 1714557600000,
- "updatedAt": 1714557600000
}
], - "nextCursor": null,
- "hasMore": false
}Idempotent upsert — sending the same body twice is a no-op on the
second call. Block-checked symmetrically: if either party has
blocked the other, the request is rejected with BLOCKED.
Aliases are 1–64 characters; emojis allowed; no censor applied.
| targetUserId required | string |
| alias required | string [ 1 .. 64 ] characters |
{- "alias": "Annem"
}{- "creatorUserId": "u_8f3c1a",
- "targetUserId": "u_2bc09f",
- "alias": "Aşkım",
- "createdAt": 1714557600000,
- "updatedAt": 1714557600000
}| type | string Enum: "group" "vip" Example: type=group Filter by room type. |
| page | integer >= 1 Default: 1 Example: page=1 |
| limit | integer [ 1 .. 100 ] Default: 20 Example: limit=20 |
{- "rooms": [
- {
- "id": "room_6812a3b4c5d6e7.12345678",
- "zegoRoomID": "string",
- "title": "string",
- "imageUrl": "string",
- "listenerCount": 0,
- "speakerCount": 0,
- "topUserAvatarUrls": [
- "string"
], - "isVideoEnabled": true,
- "seatCount": 0,
- "color": "#DC11FF",
- "isFeatured": true,
- "type": "group",
- "category": "string",
- "wheel": {
- "wheelId": 0,
- "code": "main_wheel_v1"
}, - "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0
}
], - "total": 0,
- "page": 0,
- "limit": 0
}| name | string <= 255 characters Kullanıcının seçtiği oda adı ( |
| title | string <= 255 characters Geriye dönük alan; |
| 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. |
{- "name": "string",
- "title": "string",
- "zegoRoomID": "string",
- "type": "group",
- "category": "general",
- "seatCount": 4,
- "isVideoEnabled": false,
- "imageUrl": "string"
}{- "id": "room_6812a3b4c5d6e7.12345678",
- "zegoRoomID": "string",
- "title": "string",
- "imageUrl": "string",
- "listenerCount": 0,
- "speakerCount": 0,
- "topUserAvatarUrls": [
- "string"
], - "isVideoEnabled": true,
- "seatCount": 0,
- "color": "#DC11FF",
- "isFeatured": true,
- "type": "group",
- "category": "string",
- "wheel": {
- "wheelId": 0,
- "code": "main_wheel_v1"
}, - "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0,
- "expiresAt": "2019-08-24T14:15:22Z",
- "coinCharged": 0,
- "usedAllowance": true
}Literal path — must be registered before GET /api/voice-rooms/{roomID} so client-config is not parsed as a room id.
Same VOICE_ROOM_* values as VoiceRoomService (takeSeat, extendSeat, VIP create).
{- "serverTimeMs": 0,
- "seatDurationSeconds": 0,
- "freeDailyLimit": 0,
- "seatTakeCost": 0,
- "extendSeconds": 0,
- "extendCoinCost": 0,
- "vipCreateCoinCost": 0,
- "presenceGraceSeconds": 0,
- "heartbeatTimeoutSeconds": 0,
- "vipHostGraceSeconds": 0
}Returns room snapshot for the authenticated user. Includes freeUsedToday,
freeLimit, and extraSitsLeft (daily sit economy, same semantics as takeSeat).
| roomID required | string |
{- "id": "room_6812a3b4c5d6e7.12345678",
- "zegoRoomID": "string",
- "title": "string",
- "imageUrl": "string",
- "listenerCount": 0,
- "speakerCount": 0,
- "topUserAvatarUrls": [
- "string"
], - "isVideoEnabled": true,
- "seatCount": 0,
- "color": "#DC11FF",
- "isFeatured": true,
- "type": "group",
- "category": "string",
- "wheel": {
- "wheelId": 0,
- "code": "main_wheel_v1"
}, - "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0
}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.
| roomID required | string |
| userName | string |
| avatarUrl | string |
{- "userName": "string",
- "avatarUrl": "string"
}{- "listenerCount": 0,
- "alreadyMember": true,
- "isHost": true,
- "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0
}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.
| roomID required | string |
| isSpeaker | boolean Default: false Üyenin konuşmacı koltuğunda olduğunu (ürün tanımınıza göre) ifade eder.
|
{- "isSpeaker": false
}{- "ok": true
}Increments the target user's users.heart_count. A heart from the
same sender to the same target can be sent only once (enforced by a
user_heart_edges unique index → DUPLICATE_HEART on retry).
Request body target key — three accepted forms (controller
tries in order): target_user_id, targetUserId, targetUserID.
New clients SHOULD use targetUserID for consistency with other
voice-room endpoints. The first non-empty value wins.
Broadcasts heart_sent over WebSocket with the target's new
heart_count.
| roomID required | string |
| target_user_id required | string User key of the heart recipient. |
{- "target_user_id": "string"
}{- "success": true,
- "new_heart_count": 0
}Seat economy (all thresholds configurable via .env; defaults shown):
VOICE_ROOM_FREE_DAILY_LIMIT (default 5) free sits per UTC day,
tracked in voice_room_daily_sits.free_used.extra_sits balance.VOICE_ROOM_SEAT_TAKE_COST coins (default 50)
via WalletService::spendCoins — idempotent on the caller's
idempotencyKey or a server-derived fallback
(seat_take:{roomId}:{seatIndex}:{userId}:{YYYY-MM-DD}).The seat timer then runs for VOICE_ROOM_SEAT_DURATION_SECONDS
(default 360s). The response's freeUsedToday/freeLimit/
extraSitsLeft reflect the caller's daily state after this take.
Ghost seat (server reconcile): If the slot still has an active
non-expired timer but the occupant's voice_room_members.last_seen
is past VOICE_ROOM_HEARTBEAT_TIMEOUT_SECONDS (same rule as stale
eviction), the server atomically clears that seat (and evicts the
occupant's membership when it is another user) before applying the
normal take rules. Redis may emit seat_left with
reason: heartbeat_timeout ahead of the usual seat_taken.
| roomID required | string |
| seatIndex required | integer >= 0 |
| idempotencyKey | string Optional client-supplied UUID. Recommended when the take will charge coins (no free/extra sits left) so retries don't double-debit. If omitted the server derives a best-effort key. |
{- "seatIndex": 0,
- "idempotencyKey": "string"
}{- "expiresAt": 0,
- "serverTimeMs": 0,
- "seatDurationSeconds": 0,
- "heartCount": 0,
- "coinCharged": 0,
- "freeUsedToday": 0,
- "freeLimit": 0,
- "extraSitsLeft": 0
}| roomID required | string |
| seatIndex required | integer >= 0 |
{- "seatIndex": 0
}{- "message": "Koltuk bırakıldı."
}Pushes the caller's seat expiry out by VOICE_ROOM_EXTEND_SECONDS
(default 60s) and debits VOICE_ROOM_EXTEND_COIN_COST coins
(default 0 — when zero, no wallet call is made). New expiry is
max(previousExpiresAtMs, nowMs) + addedSeconds*1000, so
extending a just-expired seat starts the new window from now.
Caller must be the seat occupant (NOT_YOUR_SEAT otherwise).
Server broadcasts seat_timer_extended to the room.
Idempotency: if idempotencyKey is omitted, the server derives
one from (roomId, seatIndex, userId, previousExpiresAt) — which
means rapid double-clicks on the same seat-with-same-expiry are
deduped, but different extends on the same seat are distinct.
| roomID required | string |
| seatIndex required | integer >= 0 |
| idempotencyKey | string Optional client-supplied UUID. Every extend charges coins, so supplying this lets network retries dedupe safely. If omitted the server derives a key from the current seat expiry. |
{- "seatIndex": 0,
- "idempotencyKey": "string"
}{- "newExpiresAt": 0,
- "coinCharged": 0,
- "addedSeconds": 0,
- "usedAllowance": true
}Same timer math as /seats/extend (adds VOICE_ROOM_EXTEND_SECONDS,
default 60s) but the caller pays VOICE_ROOM_EXTEND_COIN_COST
(default 0). The caller does NOT need to occupy a seat — only be
an active room member — but targetUserID MUST currently occupy
the named seatIndex (TARGET_NOT_IN_SEAT otherwise).
Server broadcasts seat_timer_extended to the room; the event's
userId field refers to the seat occupant (the gift recipient),
not the caller.
| roomID required | string |
| targetUserID required | string |
| seatIndex required | integer >= 0 |
| idempotencyKey | string Optional client-supplied UUID. See ExtendSeatBody. |
{- "targetUserID": "string",
- "seatIndex": 0,
- "idempotencyKey": "string"
}{- "newExpiresAt": 0,
- "coinCharged": 0,
- "addedSeconds": 0,
- "usedAllowance": true
}Multiplayer-game ledger surface. Player-facing /api/games/{gameType}/join
debits the entry fee from the JWT-bound user. Backend is the only
authoritative source on amounts — wire carries gameType, never coin
amounts. Catalog lives in the games table. The game-server-facing
settle/payout endpoint lives in the separate Game-Server API doc
under its own X-Game-Server-Key auth.
Player-facing entry-fee debit. JWT-auth — the caller is the player themselves; their callerId is taken from the JWT, NEVER from the request body.
Backend looks up entry_cost for gameType in the games catalog
and debits the user's wallet via WalletService::spendCoins (atomic,
ledgered, RC-mirrored, idempotent on idempotencyKey).
The request carries no amount: the backend is the only authoritative source on coin movement amounts.
Idempotent on (gameType, idempotencyKey). Re-sending the same key
returns the original outcome with replayed: true and does not
double-debit.
The optional app_page body field controls whether a debit
actually happens. When omitted/empty, the call is a
verification only: catalog, caller, AND balance are validated
(so an under-funded user gets the same 402 INSUFFICIENT_COINS
they'd get from the real debit), but no coin moves and no
ledger row is written. When set, the entry fee is actually
debited as described above. The home-page game tile uses
verification mode; the game's own start page sends app_page
to perform the real debit on Play.
| gameType required | string^[a-z0-9_-]{1,32}$ Example: ludo Game type key (catalog |
| idempotencyKey required | string [ 1 .. 128 ] characters Client-supplied stable identifier for this join attempt. Must be
unique per logical join. Re-sending the same key returns the
original outcome with |
| app_page | string <= 64 characters Optional client-supplied origin tag for this join. When
omitted (or empty), the call is treated as a verification
only — catalog, caller, AND balance are validated (so an
under-funded user gets 402 INSUFFICIENT_COINS just like the
real debit would return), but no coin is debited and no
ledger row is written. When non-empty, the entry fee is
debited as usual.
Use case: the home-page game tile sends no |
{- "idempotencyKey": "a3f1c0b2-9d2e-4f1a-b3c4-5e6f7a8b9c0d",
- "app_page": "ludo_start"
}{- "gameType": "ludo",
- "entryCost": 100,
- "ledgerId": 12345,
- "balanceAfter": 400,
- "referenceId": "game_entry:ludo:a3f1c0b2-...",
- "replayed": true
}Returns the wheel definition currently attached to this room (section count, bet mode, round duration, allowed stake amounts, and whether the host may toggle the wheel on/off).
The toggleable flag reflects room type: false for public
(group) rooms where the wheel always runs while listeners are
present, true for private (vip) rooms where the host drives
is_enabled. Clients should use it to show or hide the
enable/disable button.
Visual data (colors, labels, icons) is intentionally NOT served —
the mobile client renders sections from its own asset pack keyed
by (code, sectionIndex).
| roomID required | string |
{- "code": "string",
- "sectionCount": 0,
- "betMode": "none",
- "roundDurationMs": 0,
- "betOptions": [
- 0
], - "sections": [
- {
- "sectionIndex": 0,
- "rewardKind": "coin_multiplier",
- "multiplier": 1,
- "itemRef": "string",
- "itemQty": 1
}
], - "toggleable": true,
- "maxDistinctSectionsPerUserPerRound": 1,
- "betLockMs": 30000
}Returns a snapshot of the in-flight round (state betting or
spinning), including per-section bet totals and the caller's own
bets. Returns {round: null} between rounds (idle gap).
For real-time updates use the WebSocket events
wheel_round_started, wheel_round_tick, wheel_bet_placed,
wheel_spin_started, wheel_round_resolved.
| roomID required | string |
{- "round": {
- "roundId": 0,
- "state": "betting",
- "startedAtMs": 0,
- "endsAtMs": 0,
- "sectionTotals": [
- {
- "sectionIndex": 0,
- "totalBet": 0,
- "betCount": 0
}
], - "mine": [
- {
- "betId": 0,
- "roundId": 0,
- "sectionIndex": 0,
- "amount": 0,
- "placedAtMs": 0,
- "payoutStatus": "pending",
- "payoutKind": "none",
- "payoutAmount": 0,
- "payoutItemRef": "string",
- "payoutItemQty": 0
}
]
}, - "nextStartAtMs": 0
}Validates round window + bet options, debits coins via the wallet
with idempotency key wheel_bet:{roundId}:{idempotencyKey}, then
inserts the bet row. Replays of the same idempotencyKey for the
same round return the prior bet without re-debiting.
Bets are accepted only while the round is in betting state and
now < endsAtMs.
| roomID required | string |
| roundId required | integer Active round ID returned by GET /wheel/round. |
| sectionIndex required | integer >= 0 Zero-based section index in [0, wheel.sectionCount). |
| amount required | integer >= 1 Stake. Must exactly match one of the values from
|
| idempotencyKey required | string Client-supplied idempotency key (e.g. UUIDv4). Replays of the
same key for the same round return the prior bet without
re-debiting coins. Wallet-side debit is namespaced as
|
{- "roundId": 0,
- "sectionIndex": 0,
- "amount": 1,
- "idempotencyKey": "string"
}{- "bet": {
- "betId": 0,
- "roundId": 0,
- "sectionIndex": 0,
- "amount": 0,
- "placedAtMs": 0
}
}Refunds every coin from the user's pending bets in the active
round, marks each bet refunded, and broadcasts a
wheel_bets_reset WS frame so all clients in the room re-render
section totals. The originating client uses the broadcast's
userId field to additionally clear its own "selected sections"
UI state.
Per-bet refunds use the deterministic wallet key
wheel_bet_refund:{betId}, so retries (network blip, accidental
double-tap) never double-credit. Bets that the resolve path
claimed (won/lost) between the user's tap and the refund loop
are skipped silently — the response counters reflect what was
actually refunded.
Refused while round is not in betting state OR past endsAtMs
(ROUND_NOT_OPEN).
| roomID required | string |
{- "reset": {
- "roundId": 0,
- "betsRefunded": 0,
- "coinsRefunded": 0
}
}Returns full round detail: state, timestamps, winning section,
per-section bet totals, and — once state = resolved — the frozen
per-section probability snapshot in basis points (weightsBp,
sum = 10000). Useful for client-side audit/replay.
| roomID required | string |
| roundID required | integer |
{- "round": {
- "roundId": 0,
- "state": "pending",
- "startedAtMs": 0,
- "endsAtMs": 0,
- "resolvedAtMs": 0,
- "winningSectionIndex": 0,
- "sectionTotals": [
- {
- "sectionIndex": 0,
- "totalBet": 0,
- "betCount": 0
}
], - "weightsBp": {
- "property1": 0,
- "property2": 0
}
}
}Host-only. Attaches the wheel identified by wheelCode to this
room via an idempotent upsert into voice_room_wheels
(is_enabled=1 on the row). Publishes a wheel_attached bridge
event so the running WebSocket worker starts ticking rounds
without a restart.
Semantics per room type:
voice_rooms.type='group'): attaching IS the
whole action — the wheel runs whenever listeners are present.
There is no separate enable/disable toggle, and POST
/wheel/disable returns 409 WHEEL_NOT_TOGGLEABLE.voice_rooms.type='vip'): attaching also sets
is_enabled=1 so the first attach starts rounds; the host can
later toggle the wheel off with POST /wheel/disable and back on
by calling POST /wheel/enable again (the upsert resets
is_enabled=1).| roomID required | string |
| wheelCode required | string Wheel definition code (e.g. "classic_8"). Must exist and be
active in the |
{- "wheelCode": "string"
}{- "wheel": {
- "wheelId": 0,
- "code": "string"
}
}Host-only, private rooms only. Sets
voice_room_wheels.is_enabled = 0 and publishes a
wheel_disabled bridge event so the WebSocket worker stops
ticking new rounds for this room.
This endpoint is not valid for public rooms
(voice_rooms.type='group'): their wheel is always on while
listeners are present and the call returns
409 WHEEL_NOT_TOGGLEABLE. Use the toggleable flag from
GET /wheel/config to decide whether to show the disable button.
NOTE: An in-flight round is NOT auto-cancelled by this call; its DB row keeps its current state. The WS worker simply stops advancing it. Operator-level cleanup is required for stuck rounds.
| roomID required | string |
{- "ok": true
}Returns the top room-wheel winners across all rooms for a rolling
time window. Ranking metric is gross winnings:
SUM(payout_amount) over the user's resolved bets (won or
lost) inside the window. Refunded and pending bets are
excluded. Only users with positive winnings are returned. 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.
| period | string Default: "daily" Enum: "daily" "weekly" "monthly" Rolling time window. Defaults to |
| limit | integer [ 1 .. 100 ] Default: 50 Max entries returned. Clamped to |
{- "period": "daily",
- "sinceMs": 0,
- "untilMs": 0,
- "resetAtMs": 0,
- "leaderboard": [
- {
- "userId": "string",
- "userName": "string",
- "avatarUrl": "string",
- "profit": 0,
- "totalStake": 0,
- "totalPayout": 0,
- "winningBetCount": 0
}
]
}Resolves the active daily wheel via app_config.daily_wheel_code
and returns:
sectionCount, dailyLimit.sections[]) — sectionIndex + reward
descriptor for every section, so the client can render the wheel
without a second round-trip.remainingToday spins for the current server
date (Y-m-d).resetAt and serverTime (formatted UTC
datetime strings, DD:MM:YYYY HH:mm:SS:MS) so the client can
run a correct, locally-rendered countdown to the next daily
reset. The server does not assume a client timezone.Visual data (colors, labels, icons) is intentionally NOT served —
the mobile client renders sections from its own asset pack keyed
by (wheelCode, sectionIndex).
{- "wheelId": 0,
- "wheelCode": "string",
- "sectionCount": 0,
- "dailyLimit": 10,
- "remainingToday": 0,
- "extraSpinsAvailable": 0,
- "sections": [
- {
- "sectionIndex": 0,
- "reward": {
- "kind": "coin",
- "amount": 0,
- "itemRef": "string",
- "itemQty": 0,
- "itemDisplayNameTr": "string"
}
}
], - "resetAt": "17:04:2026 00:00:00:000",
- "serverTime": "16:04:2026 14:23:12:456"
}Atomic single-player spin. Checks the caller's remaining spins for
today, increments the counter, creates a scope='daily' round
with a frozen per-section probability snapshot, draws a winner via
HMAC(seed, roundId), and grants the reward directly:
coin_flat section → coins credited via the wallet.item section → item recorded on the round; stake refund does
not apply (daily spins have no stake).Idempotency is mandatory. Retries with the same
idempotencyKey replay the original round result (replayed: true)
without consuming another daily spin or re-granting the reward.
The index (wheel_id, user_id, idempotency_key) enforces this
uniqueness server-side.
| idempotencyKey required | string Client-supplied idempotency key (e.g. UUIDv4). Mandatory.
Retries with the same key replay the original spin result
without consuming another daily try or re-granting the reward.
The unique index on |
{- "idempotencyKey": "string"
}{- "roundId": 0,
- "winningSectionIndex": 0,
- "rewardKind": "coin",
- "amount": 0,
- "itemRef": "string",
- "itemQty": 0,
- "itemDisplayNameTr": "string",
- "remainingToday": 0,
- "extraSpinsAvailable": 0,
- "replayed": true
}Returns the caller's daily-wheel rounds for the active wheel,
most-recent first. Each entry includes the winning section and a
reconstructed reward descriptor derived from the current section
config (not from an independently stored payout row — daily spins
have no wheel_bets record).
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 Page size (clamped to [1, 200]). |
{- "rounds": [
- {
- "roundId": 0,
- "state": "pending",
- "startedAtMs": 0,
- "resolvedAtMs": 0,
- "winningSectionIndex": 0,
- "reward": {
- "kind": "coin",
- "amount": 0,
- "itemRef": "string",
- "itemQty": 0,
- "itemDisplayNameTr": "string"
}
}
]
}Returns the next daily-spin reset as a formatted
DD:MM:YYYY HH:mm:SS:MS string in UTC, plus the current
server clock in the same format, so the client can render a
drift-corrected, locally-formatted countdown. The server is
timezone-agnostic — localization is the client's responsibility.
The same data is also included in /config; this endpoint exists
for clients that only need the timing (e.g. a background timer)
without fetching the whole wheel config.
{- "resetAt": "17:04:2026 00:00:00:000",
- "serverTime": "16:04:2026 14:23:12:456"
}Returns one entry per active catalog item (allowance_items with
is_active=1). Ensures a user_allowances row exists per item,
applies lazy expiry under lock, and resolves the user's tier via
VipService. Items the tier treats as unlimited
(isEnforced=false) are returned with balance: null and
unlimited: true.
{- "items": [
- {
- "itemKey": "free_gift",
- "displayName": "Ücretsiz hediye hakkı",
- "balance": 1,
- "maxCount": 1,
- "expiryMode": "none",
- "expiresAt": "2026-04-22 00:00:00",
- "tier": "normal",
- "unlimited": true
}
], - "resetAt": "22:04:2026 00:00:00:000",
- "serverTime": "21:04:2026 18:42:17:013"
}Identical semantics to /api/wheel/daily/reset-time. The
boundary is system_config.daily_reset_hour_utc (default 0,
i.e. UTC midnight).
{- "resetAt": "22:04:2026 00:00:00:000",
- "serverTime": "21:04:2026 18:42:17:013"
}| itemKey required | string Catalog item key (e.g. |
{- "itemKey": "free_gift",
- "displayName": "Ücretsiz hediye hakkı",
- "balance": 1,
- "maxCount": 1,
- "expiryMode": "none",
- "expiresAt": "2026-04-22 00:00:00",
- "tier": "normal",
- "unlimited": true
}User-facing avatar catalog, ownership, purchase, and active-avatar
selection. Ownership lives in user_avatars; the active avatar is
user_profiles.avatar_id.
Returns the avatars catalog filtered to rows where is_usable=1.
Rows with is_listed=0 are hidden by default but stay visible to
any caller who already owns the avatar (so they can re-equip it
from their inventory). Each row carries isOwned (whether the
caller has a user_avatars row) and isActive (whether the
avatar is the caller''s currently active avatar in
user_profiles.avatar_id).
| gender | string Enum: "male" "female" "other" Example: gender=female |
| tier | string Enum: "common" "rare" "epic" "legendary" Example: tier=rare |
| ownedOnly | boolean Default: false Example: ownedOnly=false |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "id": 7,
- "name": "Kara Kedi",
- "gender": "male",
- "imageUrl": "/uploads/avatars/1709_abc.webp",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "sortOrder": 10,
- "isUsable": true,
- "isListed": true,
- "createdAt": "2026-04-20 12:34:56",
- "isOwned": true,
- "isActive": true
}
], - "total": 42,
- "limit": 30,
- "offset": 0
}Debits the caller's coin balance by avatars.price via
WalletService::spendCoins (atomic, ledgered, idempotent on
the namespaced key avatar_buy:<userId>:<avatarId>:<clientKey>),
then inserts a user_avatars row with source='purchase'.
Free-tier avatars (price=0) are granted directly without a
wallet debit. Does NOT auto-equip — use POST /v1/me/avatar
after purchase.
| avatarId required | integer >= 1 |
| idempotencyKey required | string non-empty Client-generated idempotency key (typically a UUIDv4). The
server wraps this as
|
{- "idempotencyKey": "string"
}{- "ok": true,
- "avatarId": 7,
- "replayed": true,
- "priced": true,
- "balanceAfter": 0,
- "ledgerId": 0,
- "tier": "common",
- "name": "string"
}All avatars the caller owns (user_avatars rows joined with
avatars metadata). Flagged with source (purchase/grant/default)
and isActive (matches user_profiles.avatar_id).
{- "items": [
- {
- "id": 7,
- "name": "Kara Kedi",
- "gender": "male",
- "imageUrl": "/uploads/avatars/1709_abc.webp",
- "price": 500,
- "tier": "common",
- "isDefault": true,
- "sortOrder": 10,
- "isUsable": true,
- "isListed": true,
- "createdAt": "2026-04-20 12:34:56",
- "isOwned": true,
- "isActive": true,
- "source": "purchase",
- "acquiredAt": "2026-04-20 12:34:56"
}
], - "total": 5
}Returns the full avatar metadata (id, name, gender, imageUrl,
tier, price, etc.) for the caller's currently active avatar
(user_profiles.avatar_id). Ownership is double-checked against
user_avatars — if avatar_id is NULL or points at an avatar
the caller no longer owns (data-integrity drift), the endpoint
returns 404 NO_ACTIVE_AVATAR rather than leaking a broken
active state to the client.
When the caller has an approved uploaded profile photo, that
photo's absolute CDN URL replaces imageUrl and isUploaded is
true; otherwise imageUrl is the cosmetic avatar's relative path
and isUploaded is false. A pending or rejected photo is never
surfaced here.
{- "id": 7,
- "name": "Kara Kedi",
- "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
}Sets user_profiles.avatar_id to avatarId. The caller must
own the target avatar (have a user_avatars row). Use the
buy endpoint first for any avatar not yet owned.
| avatarId required | integer >= 1 |
{- "avatarId": 1
}{- "avatarId": 7,
- "imageUrl": "/uploads/avatars/1709_abc.webp",
- "tier": "common",
- "name": "string"
}User-uploaded profile photos with CDN-driven moderation. Two
X-CDN-Key callbacks push moderation state in; one JWT GET lets the
owner poll it. Distinct from the cosmetic avatars catalog — see
docs/systems/profile-images.md.
Server-to-server callback from the CDN/verification VPS. Records that
a user-uploaded photo entered moderation. Idempotent on assetId
(re-posting the same id updates the existing row), so the CDN can
retry safely. After the upsert, the user's denormalized
user_profiles.profile_image_url / profile_image_status are
recomputed. See docs/systems/profile-images.md.
| callerid required | string Owner identifier ( |
| assetId required | string <= 64 characters CDN's opaque id for the uploaded file (UNIQUE). |
| imageUrl required | string <= 500 characters CDN URL of the image bytes ( |
| status | string Default: "pending" Enum: "pending" "approved" "rejected" Initial pipeline status. Defaults to |
object (ProfileImageLabels) Free-form moderation signals attached by the CDN/verification VPS (e.g. per-category model scores). Opaque to this backend — stored verbatim as JSON and never interpreted. Nullable. |
{- "callerid": "905557773000",
- "assetId": "asset-9f3a2b",
- "status": "pending",
- "labels": {
- "nsfw": 0.01,
- "violence": 0
}
}{- "ok": true
}Server-to-server callback: end-of-pipeline status change for an
existing asset. Sets reviewed_at when the status leaves pending
and recomputes the owner's denormalized columns so an approval
immediately surfaces the URL on public read paths.
| assetId required | string <= 64 characters Example: asset-9f3a2b The CDN |
| status required | string (ProfileImageStatus) Enum: "pending" "approved" "rejected" Moderation state of a profile photo. The CDN's |
object (ProfileImageLabels) Free-form moderation signals attached by the CDN/verification VPS (e.g. per-category model scores). Opaque to this backend — stored verbatim as JSON and never interpreted. Nullable. |
{- "status": "pending",
- "labels": {
- "nsfw": 0.01,
- "violence": 0
}
}{- "ok": true
}Returns the caller's latest uploaded photo (any status), so the client can render under-review / rejected UI. Empty payload (all-null) when the user has never uploaded. Other users only ever see an approved photo, via the public-profile and voice-room read paths — not this endpoint.
{- "status": "pending",
- "updatedAtMs": 1717804800000
}Returns the public coin-pricing for 1-on-1 calls (per-minute cost, free-tier minutes, etc.) so the mobile client can show "this call will cost X coins" prompts before initiating.
The non-client-facing fields of CallPricingService (admin-only
knobs) are excluded.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Records the rating + optional issue code that the mobile client collects after a 1:1 call ends (only shown when the call lasted
= 90 seconds; that gate is mobile-side).
Idempotent on (user_id, sessionUuid) -- a duplicate POST returns
the previously-stored feedback row with replayed: true and HTTP
200 instead of 201. The caller must be one of the two participants
of the referenced call_sessions row, otherwise 403.
issueCode is required when rating <= 3 and must be one of:
audio_cut, echo, disconnect, latency, no_remote_voice.
For ratings >= 4, issueCode and issueText are ignored.
| sessionUuid required | string The |
| rating required | integer [ 1 .. 5 ] User-given star rating (1..5). |
| issueCode | string Enum: "audio_cut" "echo" "disconnect" "latency" "no_remote_voice" Required when |
| issueText | string <= 280 characters Optional free-text follow-up. Trimmed and truncated to 280 chars
server-side; rejected if it contains |
| callDurationSec | integer >= 0 Client-claimed call duration in seconds (analytics-only; server also stores its own derived duration). |
| platform | string Enum: "ios" "android" Mobile platform string. Unknown values are stored as null. |
| appVersion | string <= 32 characters Client app version (free-form, capped at 32 chars). |
| sentAtMs | integer <int64> >= 0 Client-side wall-clock timestamp (epoch ms) at submit time. Diagnostic only. |
{- "rating": 1,
- "issueCode": "audio_cut",
- "issueText": "string",
- "callDurationSec": 0,
- "platform": "ios",
- "appVersion": "string",
- "sentAtMs": 0
}{- "data": {
- "feedbackId": "8a5fb68c-aae2-438d-9a46-b4d8c8788e0b",
- "sessionUuid": "string",
- "rating": 1,
- "issueCode": "audio_cut",
- "createdAtMs": 0,
- "replayed": true
}
}Persists a 1..5 star rating left by one participant of a 1:1 call
for the other. Distinct from quality-feedback, which collects
audio/network issues; this endpoint collects social-moderation
signals (inappropriate, underage, other).
Rules:
rating < 3 requires reasonCode.reasonCode == "other" requires reasonText (max 240 chars).(userId, sessionUuid). A duplicate POST returns
HTTP 200 with replayed: true and the original row; first POST
returns HTTP 201.userId = phone) must be one of the two
participants of the session (call_sessions.user_phone1 or
user_phone2); otherwise 403 FORBIDDEN.| sessionUuid required | string Call session UUID (= |
| rating required | integer [ 1 .. 5 ] Star rating from 1 to 5. |
| reasonCode | string or null Enum: "inappropriate" "underage" "other" null Required when |
| reasonText | string or null <= 240 characters Free-text explanation. Accepted only when
|
| callDurationSec | integer or null >= 0 Client-reported call duration in seconds (analytics). |
| ratedAtMs | integer or null Client clock at submission, epoch ms (analytics). |
| platform | string or null Enum: "ios" "android" null Mobile platform (analytics). |
| appVersion | string or null <= 32 characters Mobile app version string (analytics). |
{- "rating": 1,
- "reasonCode": "inappropriate",
- "reasonText": "string",
- "callDurationSec": 0,
- "ratedAtMs": 0,
- "platform": "ios",
- "appVersion": "string"
}{- "ratingId": "e2685a8c-cb0d-4ffd-85d1-e4f8966e7aa9",
- "sessionUuid": "string",
- "rating": 1,
- "reasonCode": "inappropriate",
- "storedAtMs": 0,
- "replayed": true
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Server-initiated notification trigger. Body shape is consumer-specific (caller passes through Firebase notification payload). Returns the underlying delivery result.
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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.
Fire-and-forget endpoint called by the sender's client immediately
after ZIMKit().sendTextMessage(...) resolves. The backend never
sees the message body for storage — Zego is the message transport;
this endpoint exists solely to deliver an FCM push to the receiver's
registered devices.
Idempotent on messageId (ZIM message id). A retry of the same
messageId returns the original result without re-pushing.
Authentication is required; the sender is taken from the JWT, NOT
from the body. receiverUserId is the receiver's users.callerid
(digits-only string, same form ZIM uses as its userID).
Behaviour: if the receiver has globally muted DM pushes, no devices
registered, or has blocked the sender, the call still returns 200
with delivered: 0 (errors never roll back the chat UI).
| messageId required | string <= 64 characters ZIM message id ( |
| conversationId | string Stable conversation key. Defaults to receiver's callerid when
omitted. Used for |
| receiverUserId required | string Receiver's |
| textPreview | string Short plain-text preview to surface in the notification body. Truncated server-side to 140 characters; longer values are shortened with an ellipsis. Empty string is allowed (client may omit if the user has disabled previews locally). |
| createdAtMs | integer <int64> Epoch milliseconds when the message was sent. Optional — if 0 or missing, the server stamps with its own clock. |
{- "messageId": "1745890000000123",
- "conversationId": "5511999998888",
- "receiverUserId": "5511999998888",
- "textPreview": "Selam, naber?",
- "createdAtMs": 1745890000000
}{- "delivered": 0,
- "tokens": 0,
- "replayed": true
}Account-scoped settings: device/FCM token registration, profile bits. All endpoints require JWT auth and operate on the calling user.
Persists the caller's Firebase Cloud Messaging registration token
into user_devices so DM push notifications can target this device.
Called by the Flutter client at two points:
user_devices empty for them)
start receiving push without needing to log out/in.FirebaseMessaging.onTokenRefresh — token rotation, app
reinstall, restore-from-backup, etc.Replaces the never-routed legacy firebaseTokenUpdate endpoint.
deviceId is optional. When omitted the server synthesises one
from sha256(userId:token) — stable per (user, token) but a
rotation produces a new row (the prior row's stale token is
nulled out by DmPushService on the next send attempt that hits
UNREGISTERED).
| token required | string [ 32 .. 4096 ] characters FCM registration token ( |
| deviceType | string Free-form platform tag — typically |
| appVersion | string Client version for triage (any string the client uses). |
| deviceId | string Stable per-install identifier the client persists locally
(Hive UUID, etc.). When omitted the server synthesises an
|
{- "token": "fA9k_LdEQUe...zXq8YQ7Pr3",
- "deviceType": "ios",
- "appVersion": "9.3.2",
- "deviceId": "8f1a3b62-ce1f-4e34-9d2d-71f6f6a96bd8"
}{- "ok": true
}The client posts its versionNo (or appVersion) and platform; the
server returns whether the install needs to be updated, and whether
the update is mandatory. Public route — no JWT required.
| versionNo | string |
| appVersion | string Alias. |
| platform | string e.g. android, ios. |
| property name* additional property | any |
{- "versionNo": "string",
- "appVersion": "string",
- "platform": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Public route — no JWT required.
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Loyalty points are a separate currency from coins (see
/getCoinBalance). Used by the old reward / streak surface.
| userId | integer |
| property name* additional property | any |
{- "userId": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Endpoint name preserves the original misspelling (Substract) for
backward compatibility with mobile clients.
| userId | integer |
| amount | integer |
| property name* additional property | any |
{- "userId": 0,
- "amount": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Returns a QR-code URL or payload that the mobile client renders for
WhatsApp account linking. The actual WhatsApp integration lives in
the WhatsAppService (currently a phantom — see CLAUDE.md "Phantom
services" — implementation is external/stubbed).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Generic WhatsApp service multiplexer — the body's action field
selects the underlying operation. See the WhatsAppService
implementation (phantom; verify before relying on this endpoint).
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Inserts (or no-ops) the JWT subject's callerid into users so test
scripts can sign in via OTP and immediately operate on a known user.
Returns { userId, appUserId }.
Refused with 403 FORBIDDEN outside test env (APP_ENV !== 'testing').
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Deletes the caller's rows from wheel_rounds (daily-scope), reward_claims,
coin_history, user_wallet_summary, and users (FK CASCADE handles
user_allowances, user_allowance_history, voice-room rows via
host_user_id, etc.). Used by the integration test runner between cases.
Refused with 403 FORBIDDEN outside test env.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Admin-panel surface — catalog CRUD, tier policies, grant rules, per-user grants, history
| active | string Enum: "true" "false" Example: active=true Filter by |
| q | string Example: q=avatar_slot Partial match on |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "data": {
- "items": [
- {
- "id": 0,
- "itemKey": "free_gift",
- "displayNameTr": "Ücretsiz hediye hakkı",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true,
- "createdAt": "string",
- "updatedAt": "string"
}
], - "total": 0,
- "limit": 1,
- "offset": 0
}
}| itemKey required | string <= 64 characters ^[a-z0-9_]+$ |
| displayNameTr required | string <= 128 characters |
| descriptionTr | string |
| maxCount | integer or null >= 0 |
| expiryMode required | string Enum: "none" "per_balance" "per_grant" |
| expirySeconds | integer or null >= 1 |
| params | any Arbitrary JSON (object, array, or JSON-encoded string). |
| isActive | boolean Default: true |
{- "itemKey": "string",
- "displayNameTr": "string",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemKey": "free_gift",
- "displayNameTr": "Ücretsiz hediye hakkı",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true,
- "createdAt": "string",
- "updatedAt": "string"
}
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemKey": "free_gift",
- "displayNameTr": "Ücretsiz hediye hakkı",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true,
- "createdAt": "string",
- "updatedAt": "string"
}
}| id required | integer >= 1 |
| displayNameTr | string <= 128 characters |
| descriptionTr | string or null |
| maxCount | integer or null >= 0 |
| expiryMode | string Enum: "none" "per_balance" "per_grant" |
| expirySeconds | integer or null >= 1 |
| params | any |
| isActive | boolean |
{- "displayNameTr": "string",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemKey": "free_gift",
- "displayNameTr": "Ücretsiz hediye hakkı",
- "descriptionTr": "string",
- "maxCount": 0,
- "expiryMode": "none",
- "expirySeconds": 1,
- "params": null,
- "isActive": true,
- "createdAt": "string",
- "updatedAt": "string"
}
}Hard delete is not exposed; user_allowance_history references would orphan.
| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "id": 0,
- "isActive": false,
- "softDeleted": true
}
}| id required | integer >= 1 |
{- "status": "OK",
- "data": {
- "itemId": 0,
- "tierPolicies": [
- {
- "id": 0,
- "itemId": 0,
- "tier": "normal",
- "isEnforced": true,
- "notes": "string",
- "updatedAt": "string"
}
]
}
}| id required | integer >= 1 |
| tier required | string Enum: "normal" "vip" |
| isEnforced required | boolean |
| notes | string Pass |
{- "isEnforced": true,
- "notes": "string"
}{- "status": "OK",
- "data": {
- "itemId": 0,
- "tierPolicies": [
- {
- "id": 0,
- "itemId": 0,
- "tier": "normal",
- "isEnforced": true,
- "notes": "string",
- "updatedAt": "string"
}
]
}
}| sourceKey required | string^[a-z0-9_]+$ |
| displayName required | string |
| isActive | boolean Default: true |
{- "sourceKey": "string",
- "displayName": "string",
- "isActive": true
}{- "status": "OK",
- "data": {
- "id": 0,
- "sourceKey": "daily_reset",
- "displayName": "string",
- "isActive": true
}
}| id required | integer >= 1 |
| displayName | string |
| isActive | boolean |
{- "displayName": "string",
- "isActive": true
}{- "status": "OK",
- "data": {
- "id": 0,
- "sourceKey": "daily_reset",
- "displayName": "string",
- "isActive": true
}
}| itemId | integer Example: itemId=12 |
| sourceId | integer Example: sourceId=3 |
| tier | string Enum: "normal" "vip" Example: tier=vip |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "data": {
- "items": [
- {
- "id": 0,
- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "refillIntervalSeconds": 1,
- "strategy": "set_to",
- "isActive": true,
- "params": null,
- "createdAt": "string",
- "updatedAt": "string"
}
], - "total": 0,
- "limit": 1,
- "offset": 0
}
}(itemId, grantSourceId, tier) is unique — duplicates return
409 GRANT_RULE_CONFLICT.
| itemId required | integer |
| grantSourceId required | integer |
| tier required | string Enum: "normal" "vip" |
| amount required | integer >= 0 |
| strategy required | string Enum: "set_to" "add" |
| refillIntervalSeconds | integer or null >= 1 |
| isActive | boolean Default: true |
| params | any |
{- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "strategy": "set_to",
- "refillIntervalSeconds": 1,
- "isActive": true,
- "params": null
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "refillIntervalSeconds": 1,
- "strategy": "set_to",
- "isActive": true,
- "params": null,
- "createdAt": "string",
- "updatedAt": "string"
}
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "refillIntervalSeconds": 1,
- "strategy": "set_to",
- "isActive": true,
- "params": null,
- "createdAt": "string",
- "updatedAt": "string"
}
}itemId, grantSourceId, tier are immutable — create a new rule
to change them.
| id required | integer >= 1 |
| amount | integer >= 0 |
| strategy | string Enum: "set_to" "add" |
| refillIntervalSeconds | integer or null >= 1 |
| isActive | boolean |
| params | any |
{- "amount": 0,
- "strategy": "set_to",
- "refillIntervalSeconds": 1,
- "isActive": true,
- "params": null
}{- "status": "OK",
- "data": {
- "id": 0,
- "itemId": 0,
- "grantSourceId": 0,
- "tier": "normal",
- "amount": 0,
- "refillIntervalSeconds": 1,
- "strategy": "set_to",
- "isActive": true,
- "params": null,
- "createdAt": "string",
- "updatedAt": "string"
}
}| q | string Example: q=5678 Optional substring match on |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "data": {
- "items": [
- {
- "id": 0,
- "callerId": "string",
- "totalOpenAllowances": 0
}
], - "total": 0,
- "limit": 1,
- "offset": 0
}
}| callerId required | string <= 20 characters Phone-format caller id ( |
{- "status": "OK",
- "data": {
- "callerId": "string",
- "balances": [
- {
- "itemKey": "free_gift",
- "displayName": "Ücretsiz hediye hakkı",
- "balance": 1,
- "maxCount": 1,
- "expiryMode": "none",
- "expiresAt": "2026-04-22 00:00:00",
- "tier": "normal",
- "unlimited": true
}
]
}
}Writes directly to user_allowances.balance and
user_allowance_history. Idempotent on referenceId —
re-sending the same key returns the prior ledger row with
replayed: true and strategy: "replay", with no mutation.
| callerId required | string <= 20 characters Phone-format caller id ( |
| itemKey required | string |
| amount required | integer >= 0 |
| strategy | string Default: "set_to" Enum: "set_to" "add" |
| referenceId | string Idempotency key. Server generates one when omitted, but callers
are expected to provide a stable key — replays return the prior
ledger row with |
object |
{- "itemKey": "string",
- "amount": 0,
- "strategy": "set_to",
- "referenceId": "string",
- "metadata": { }
}{- "status": "OK",
- "data": {
- "ok": true,
- "itemKey": "string",
- "granted": 0,
- "strategy": "string",
- "previous": 0,
- "balance": 0,
- "delta": 0,
- "expiresAt": "string",
- "historyId": 0,
- "replayed": true,
- "referenceId": "string"
}
}Fails with 409 INVENTORY_EMPTY when balance would go below
zero. Idempotent on referenceId. If the user's tier is
unlimited for this item, returns {unlimited: true, balance: null}
with no mutation.
| callerId required | string <= 20 characters Phone-format caller id ( |
| itemKey required | string |
| amount required | integer >= 1 |
| referenceId | string |
object |
{- "itemKey": "string",
- "amount": 1,
- "referenceId": "string",
- "metadata": { }
}{- "status": "OK",
- "data": {
- "ok": true,
- "itemKey": "string",
- "tier": "normal",
- "consumed": 0,
- "balance": 0,
- "historyId": 0,
- "unlimited": true,
- "replayed": true,
- "referenceId": "string"
}
}| callerId | string Example: callerId=905551112233 Exact match. |
| itemKey | string Example: itemKey=free_seat Exact match. |
| source | string Enum: "grant" "consume" "expire" "refill" "admin" "refund" Example: source=consume |
| from | string Example: from=2026-05-01 00:00:00 Lower bound on |
| to | string Example: to=2026-05-05 23:59:59 Upper bound on |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "data": {
- "items": [
- {
- "id": 0,
- "userId": 0,
- "callerId": "string",
- "itemId": 0,
- "itemKey": "string",
- "amount": 0,
- "balanceAfter": 0,
- "source": "grant",
- "grantSourceId": 0,
- "grantRuleId": 0,
- "referenceId": "string",
- "metadata": null,
- "createdAt": "string"
}
], - "total": 0,
- "limit": 1,
- "offset": 0
}
}Lists rows from the avatars catalog. All /admin/* routes are
JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key).
| gender | string Example: gender=female Filter by gender ( |
| usable | string Example: usable=true
|
| listed | string Example: listed=true
|
| active | string Example: active=true Deprecated alias for |
| limit | integer Default: 30 Example: limit=30 |
| offset | integer Default: 0 Example: offset=0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Accepts optional tier (one of common|rare|epic|legendary,
defaults to common) and is_default (boolean — at most one
avatar may be marked as the system-wide default; UNIQUE).
| 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_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 |
{- "name": "string",
- "gender": "male",
- "price": 0,
- "tier": "common",
- "is_default": true,
- "is_usable": 0,
- "is_listed": 0,
- "sort_order": 0
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| id required | integer |
| property name* additional property | any |
{ }{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Hard-delete. The service migrates every active user (user_profiles.avatar_id
pointing here) onto the system default avatar before the row is removed;
the FK on user_avatars is ON DELETE CASCADE, so ownership rows for this
avatar disappear as well.
Rejected with:
AVATAR_IS_DEFAULT when the avatar is currently flagged is_default=1.
Reassign the default to another avatar first, then retry.NO_DEFAULT_AVAILABLE when active users currently wear this avatar AND
the system has no other usable avatar to migrate them to. The admin
panel should surface this as an explanatory dialog asking the operator
to mark another avatar as default before retrying.| id required | integer |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Lists rows from the interests catalog with localized names. All
/admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware
(X-Admin-Key).
| active | string Example: active=true
|
| q | string Substring match against |
| limit | integer Default: 30 Example: limit=30 |
| offset | integer Default: 0 Example: offset=0 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Creates an interest with a served icon image. image is required.
names is a JSON object of localized display names; interests.name
is the English-canonical fallback. The legacy emoji icon is optional.
| code required | string 1–50 chars, |
| name required | string 1–100 characters (canonical/en fallback). |
| icon | string Optional legacy emoji fallback. |
| isActive | boolean Default: true |
| sortOrder | integer Default: 0 |
| names | string JSON map of localized names, e.g. |
| image required | string <binary> Icon image file (png/jpeg/webp, ≤2 MB, ≤512×512 by default). |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Partial update. Supplying a new image replaces the icon and deletes
the previous file. Supplying names upserts those locales.
| id required | integer |
| code | string |
| name | string |
| icon | string |
| isActive | boolean |
| sortOrder | integer |
| names | string JSON map of localized names. |
| image | string <binary> |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}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).
| id required | integer |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Looks up the live virtual-currency balance from RevenueCat (admin
operations + cross-system reconciliation). All /admin/* routes are
JWT-exempt and guarded by AdminKeyMiddleware (X-Admin-Key header).
| appUserId required | string Example: appUserId=905551112233 RevenueCat app user id ( |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| appUserId required | string Example: appUserId=905551112233 |
| limit | integer Default: 50 Example: limit=50 |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Returns drift between user_wallet_summary (MySQL source of truth)
and RC's reported balance for the queried user(s). Used to debug
coin-system inconsistencies — see .claude/plans/wallet-system.md.
| appUserId | string Example: appUserId=905551112233 Optional — when omitted, returns a global report (slow). |
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Backs the admin-panel "Coin History" page. Reads
coin_history joined with users.display_name, decorates each
row with derived sourceTag / environmentTag / statusTag
(so the panel doesn't reimplement the decoder), and returns
top-strip KPIs in the same payload to keep auto-poll to one
round-trip.
The two label axes:
sourceTag — iap | refund | spend | reward | gift | admin | transfer | unknown. Reads metadata.source; falls back to
coin_history.type for pre-tagging-era rows
(type='purchase' → iap).environmentTag — PROD | SANDBOX | TEST. Reads
metadata.environment then metadata.rc_event.purchase_environment;
missing means PROD (treats pre-tagging rows as production).| callerId | string Example: callerId=905551112233 Exact match on |
| source | string Enum: "iap" "refund" "spend" "reward" "gift" "admin" "transfer" Example: source=spend |
| environment | string Enum: "PROD" "SANDBOX" "TEST" Example: environment=PROD |
| status | string Enum: "confirmed" "pending" "failed" Example: status=confirmed |
| dateFrom | string <date-time> Example: dateFrom=2026-05-01T00:00:00Z Inclusive lower bound on |
| dateTo | string <date-time> Example: dateTo=2026-05-05T23:59:59Z Exclusive upper bound on |
| limit | integer [ 1 .. 200 ] Default: 50 Example: limit=50 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "items": [
- {
- "id": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "userId": 0,
- "phoneNumber": "string",
- "displayName": "string",
- "amount": 0,
- "balanceAfter": 0,
- "type": "string",
- "referenceId": "string",
- "metadata": { },
- "revenuecatSynced": true,
- "revenuecatSyncedAt": "2019-08-24T14:15:22Z",
- "revenuecatSyncAttempts": 0,
- "revenuecatLastError": "string",
- "revenuecatReason": "string",
- "revenuecatReference": "string",
- "sourceTag": "iap",
- "environmentTag": "PROD",
- "statusTag": "confirmed"
}
], - "pagination": {
- "limit": 0,
- "offset": 0,
- "total": 0
}, - "kpis": {
- "totalInCirculation": 0,
- "iap24h": {
- "count": 0,
- "coins": 0
}, - "sandboxAndTest24h": {
- "count": 0,
- "coins": 0
}, - "spend24h": {
- "count": 0,
- "coins": 0
}, - "driftCount": 0
}, - "filters": {
- "sources": [
- "string"
], - "environments": [
- "string"
], - "statuses": [
- "string"
]
}
}Grants coins to an arbitrary callerId and tags the row so it's
visually distinguishable from real IAP / sandbox-IAP / real
rewards. X-Admin-Key authed and is the only path the admin-panel
uses. (Replaces the old user-self-grant /grantTestCoins,
removed pre-launch — it was JWT-only with no admin gate.)
Goes through WalletService::grantCoins, so the same sync-first
pipeline that handles every other coin movement applies (MySQL
commit then RC mirror via RevenueCatClient::adjust).
Hardcoded by the server (NEVER trusted from client):
metadata.source = "admin"metadata.subSource = "test_grant"metadata.environment = "TEST"reference_id = "admin_test_grant:<adminUserId>:<idempotencyKey>"| callerId required | string Target user callerid (digits only, see CLAUDE.md "callerid storage form"). |
| amount required | integer >= 1 |
| note | string <= 500 characters Free-text reason; lands in metadata, capped server-side. |
| idempotencyKey required | string UUID supplied by the admin-panel; namespaced into the reference_id. |
| adminUserId required | string Audit field — admin-panel session username. Required. |
{- "callerId": "string",
- "amount": 1,
- "note": "string",
- "idempotencyKey": "string",
- "adminUserId": "string"
}{- "status": "OK",
- "grant": {
- "ok": true,
- "ledgerId": 0,
- "userId": 0,
- "appUserId": "string",
- "granted": 0,
- "balance": 0,
- "type": "string",
- "referenceId": "string",
- "replayed": true
}
}Read-only forensic view of OTP issuance — phone, purpose,
IP, attempt count, status, timestamps. Backs the admin-panel
"OTP History" page. Useful for support ("OTP didn't arrive /
didn't work") and fraud triage (high attempt_count,
repeated phones from the same IP).
All /admin/* routes are JWT-exempt and guarded by
AdminKeyMiddleware (X-Admin-Key header).
Status is derived in SQL — the table only carries
is_used + expires_at:
used — is_used = 1expired — is_used = 0 AND expires_at < NOW()pending — is_used = 0 AND expires_at >= NOW()Security: never returns otp_code_hash. Each row carries a
boolean hasCode (presence) so admins can confirm a record
was created without seeing the hash.
| phone | string Example: phone=90555 Substring match against |
| purpose | string Enum: "login" "register" "verify_phone" Example: purpose=login |
| status | string Enum: "used" "pending" "expired" Example: status=used |
| ipAddress | string Example: ipAddress=203.0.113.42 Exact match on |
| minAttemptCount | integer >= 0 Example: minAttemptCount=3 Floor on |
| dateFrom | string <date-time> Example: dateFrom=2026-05-01T00:00:00Z Inclusive lower bound on |
| dateTo | string <date-time> Example: dateTo=2026-05-05T23:59:59Z Exclusive upper bound on |
| limit | integer [ 1 .. 200 ] Default: 50 Example: limit=50 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "status": "OK",
- "items": [
- {
- "id": 0,
- "phone": "string",
- "purpose": "login",
- "status": "used",
- "hasCode": true,
- "code": "string",
- "ipAddress": "string",
- "attemptCount": 0,
- "isUsed": true,
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "usedAt": "2019-08-24T14:15:22Z"
}
], - "pagination": {
- "limit": 0,
- "offset": 0,
- "total": 0
}, - "kpis": {
- "total": 0,
- "used": 0,
- "expired": 0,
- "pending": 0,
- "uniquePhones": 0
}, - "filters": {
- "purposes": [
- "string"
], - "statuses": [
- "string"
]
}
}Admin monitor for the RevenueCat coin-sync pipeline (worker liveness, queue depth, recent failures)
Read-only inspector for the RevenueCat coin-sync pipeline. Surfaces:
revenuecat:worker:heartbeat). Status is active when the heartbeat
is < 10s old, stale when 10–30s old, and down past 30s or missing.revenuecat:coin_sync_queue, LIST) and retry queue
(revenuecat:coin_sync_retry, ZSET keyed by retry-at epoch ms).coin_history
rows still pending, currently retrying (attempts > 0), and rows
successfully synced today. Rows marked terminally undeliverable
(revenuecat_reason IS NOT NULL) are excluded from these
counts and surfaced separately under abandonedByReason.INVALID_DATA (local
pre-RC validation failed) and MAX_ATTEMPTS (exceeded retry
cap). GHOST_USER and RC_FATAL_OTHER are reserved for
future manual admin classification — RC 4xx responses are
deliberately left visible at revenuecat_synced=0 (no
reason) because the same error can mean drift, which is
exactly what the monitor exists to surface.?limit= rows from
coin_history where amount < 0 AND revenuecat_synced = 0,
ordered with still-in-play rows first and abandoned rows last.
revenuecat_last_error is truncated to 500 characters
server-side. revenuecatReason is non-null for abandoned
rows.ok / warn / crit rollup driven by
worker status + queue depth + retry/failure counts.All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware
(X-Admin-Key header). Used by the admin-panel monitoring page.
| limit | integer [ 1 .. 200 ] Default: 50 Example: limit=50 Page size for the items array (clamped 1..200, default 50). |
| offset | integer >= 0 Default: 0 Example: offset=0 Row offset for server-side pagination. Use with |
{- "status": "OK",
- "data": {
- "worker": {
- "status": "active",
- "lastHeartbeatMs": 0,
- "ageMs": 0
}, - "redis": {
- "mainQueue": 0,
- "retryQueue": 0
}, - "database": {
- "pendingNegativeSync": 0,
- "failedRetrying": 0,
- "syncedToday": 0,
- "abandonedByReason": {
- "GHOST_USER": 0,
- "INVALID_DATA": 0,
- "RC_FATAL_OTHER": 0,
- "MAX_ATTEMPTS": 0
}, - "abandonedTotal": 0
}, - "items": [
- {
- "id": 0,
- "phoneNumber": "string",
- "amount": 0,
- "type": "string",
- "referenceId": "string",
- "revenuecatReference": "string",
- "revenuecatSynced": true,
- "revenuecatSyncAttempts": 0,
- "revenuecatLastError": "string",
- "revenuecatReason": "GHOST_USER",
- "revenuecatSyncedAt": "string",
- "createdAt": "string"
}
], - "pagination": {
- "limit": 0,
- "offset": 0,
- "total": 0
}, - "overallHealth": "ok",
- "serverTimeMs": 0
}
}Clear revenuecat_reason, revenuecat_last_error, and
revenuecat_sync_attempts on each matching row, then push the row
back onto the worker's main queue. Use after fixing the underlying
cause of a class of failures — for example after rotating the
RevenueCat API key and restarting the long-running daemons that
cached the old value.
Caller must supply EITHER an explicit ids list OR a reason
filter (optionally narrowed by since and limit). The two forms
are mutually exclusive at the application level — if both are
present, ids wins.
The endpoint is idempotent at the row level: rows that were
cleared but no longer eligible (e.g. concurrent worker resolution)
are counted under skipped and not requeued. Successfully
requeued ids are returned so the panel can correlate.
| ids | Array of integers[ items >= 1 ] Explicit |
| reason | string Enum: "GHOST_USER" "INVALID_DATA" "RC_FATAL_OTHER" "RC_DRIFT" "MAX_ATTEMPTS" Restrict to rows currently marked with this reason. |
| since | string Only rows with |
| limit | integer [ 1 .. 500 ] Default: 100 Cap on rows to re-enqueue per call. Default 100, max 500. |
{- "ids": [
- 39,
- 40,
- 41,
- 42,
- 43
]
}{- "status": "OK",
- "data": {
- "requeued": 0,
- "ids": [
- 0
], - "skipped": 0,
- "message": "string"
}
}For an RC_DRIFT or RC_FATAL_OTHER (or any non-NULL
revenuecat_reason) row, read both sides' current balances,
optionally call RC's adjust API to bring RC into lockstep with
MySQL (MySQL is authoritative), write a zero-amount
type='reconcile' audit row that preserves the original
transaction's metadata.source, and mark the original row
revenuecat_reason='RECONCILED'.
Two-phase by contract — pass dryRun:true first to preview what
would change, then dryRun:false to apply.
The audit row is finance/audit's evidence trail. Its metadata
carries: the original row id and reason, the operator id and
their typed reason, the RC and MySQL balances at reconciliation
time, the delta applied to RC, and the RC HTTP status. Filtering
the coin-history panel by source returns the original
transaction AND its reconciliation side-by-side — the full story
is intact.
RC's virtual_currencies/transactions adjustments do not appear
in RC's monetary finance reports (those only cover real-money
IAP). The reconcile is invisible to finance dashboards by design.
| coinHistoryId required | integer >= 1 The |
| operatorId required | string Identifier of the admin performing the reconcile. Stored on
the audit row's |
| operatorReason required | string Free-text justification (Turkish or English). Stored on the
audit row's |
| dryRun | boolean Default: false When |
{- "operatorId": "admin@telpass",
- "operatorReason": "RC was zeroed at launch; balances now match",
- "dryRun": true
}{- "status": "OK",
- "data": {
- "dryRun": true,
- "originalRowId": 0,
- "originalReason": "string",
- "callerId": "string",
- "mysqlBalance": 0,
- "rcBalanceBefore": 0,
- "rcDeltaWouldApply": 0,
- "rcBalanceAfter": 0,
- "auditReferenceId": "string"
}
}Returns the full set of runtime-tunable knobs from system_config
(e.g. otp_max_hourly_requests, daily_reset_hour_utc,
wheel_daily_spin_limit_default/_vip).
All /admin/* routes are JWT-exempt and guarded by AdminKeyMiddleware.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}| key required | string |
required | string or integer or boolean or number Type-coerced server-side per the knob's declared type. |
{- "value": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Same handler as PUT for clients that can only POST.
| key required | string |
required | string or integer or boolean or number |
{- "value": "string"
}{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Resets every user's daily_wheel_spin allowance to the configured
tier amount. Intended to be called by a daily cron job. JWT-exempt
because it runs unattended; locked down by network ACL.
No request body. Returns a summary of how many users were touched.
{- "status": "OK",
- "message": "string",
- "desc": "string",
- "data": null,
- "error": "string"
}Returns the IVR-derived VIP status for the given callerId. Same
active-VIP rule as /vip/me: uuid set AND subscriptionsEnd
in the future.
| callerId required | string Phone number in callerid form (digits only; leading |
{- "callerId": "string",
- "data": {
- "isVip": true,
- "status": "active",
- "source": "live",
- "stale": true,
- "tier": "vip",
- "productId": "string",
- "startedAtMs": 0,
- "expiresAtMs": 0
}
}Ses odası moderasyonu — aktif odaları ada göre arama ve admin zorla kapatma
(X-Admin-Key).
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.
| 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 |
{- "rooms": [
- {
- "roomId": "string",
- "roomName": "string",
- "zegoRoomID": "string",
- "type": "group",
- "hostCallerId": "string",
- "listenerCount": 0,
- "speakerCount": 0,
- "createdAt": "string",
- "createdAtMs": 0
}
], - "total": 0,
- "page": 0,
- "limit": 0
}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.
| roomID required | string |
{- "error": {
- "code": "ROOM_NOT_FOUND",
- "message": "Oda bulunamadı.",
- "details": { }
}
}Admin per-user state operations. FTU reset = bring this user back to
first-time-user state (preserve identity + IAP audit, wipe gameplay
state, mirror RC zero, IVR VIP delete). Two-phase by contract:
dryRun:true first, then dryRun:false.
Brings the target user back to a fresh-signup state without deleting
the users row, OAuth identities, or IAP forensic trails. Mirror in
spirit of database/maintenance/launch_reset_truncate.sql but scoped
to a single callerid and live-safe.
Two-phase by contract. First call with dryRun:true to inspect
what would be wiped. Re-call with dryRun:false to apply.
What gets wiped (live-mode):
coin_history row tagged with
reference_id="ftu_reset:{callerId}:{date}" and metadata
reason="ftu_reset". coin_history itself is NOT truncated —
the row is preserved for monitoring/finance reporting.user_allowances,
user_allowance_history).reward_claims).wheel_bets, user_daily_wheel_spins,
scope=daily rows in wheel_rounds).voice_room_seat_timers,
voice_room_daily_sits, voice_room_members).room_closed broadcast to listeners).subscriptionsDelete. IVR
failure does NOT abort the rest of the reset; surfaced in
result.vip so the admin can retry manually.user_sessions, user_refresh_tokens, user_devices
cleared so the next login forces a full re-auth flow.users (calls / swipes / hearts / last_login_at).daily_streak_users/{callerId} doc) is deleted so the
re-FTU'd user starts day-0 on next claim. Best-effort:
Firestore unreachable → skip rather than abort the reset.What is preserved:
users row itself (callerid, uuid, user_key, display_name).user_identities (OAuth bindings).user_purchases, paymentHistory, revenuecat_webhook_events
(IAP forensic trails).coin_history (we add one synthetic row, never truncate).Idempotency. Defaults to ftu_reset:{callerId}:{YYYY-MM-DD}.
Same-day replays no-op via the wallet's reference_id unique index
and RC's Idempotency-Key header. Override only if testing
multi-reset-per-day flows.
| callerId required | string Target user callerid (digits-only, max 20 chars). |
| dryRun | boolean Default: true When true (default), no writes happen — response contains a
|
| idempotencyKey | string Optional. Defaults to |
{- "dryRun": true,
- "idempotencyKey": "string"
}{- "status": "OK",
- "data": {
- "dryRun": true,
- "preview": {
- "callerId": "string",
- "userId": 0,
- "userKey": "string",
- "walletBalance": 0,
- "rcBalance": 0,
- "rcReadStatus": "string",
- "vipUuid": "string",
- "hostedRoomIds": [
- "string"
], - "bucketCounts": {
- "property1": 0,
- "property2": 0
}, - "dailyStreak": {
- "status": "present",
- "cycleId": "string",
- "dayIndex": 0,
- "error": "string"
}
}
}
}Admin → user message pool. Direct sends, listing, and force-delete.
Templates / broadcasts / auto-rules ship in P1/P2 — see
.claude/plans/admin-system-messages.md.
Inserts one row per userIds entry into system_messages with
delivery_status='pending'. The dispatcher cron (bin/sysmsg_dispatcher.php,
every minute) picks them up and fires FCM push asynchronously — the
HTTP response returns the moment the rows are persisted, NOT after
the push completes. Per .claude/plans/admin-system-messages.md v1
does NOT do template substitution here; pass already-rendered text.
| userIds | Array of integers <int64> [ items <int64 > >= 1 ] One or more |
| callerIds | Array of strings Phone-style caller ids (digits only). Resolved server-side to
|
| title required | string <= 255 characters Notification title (already-rendered text; no template substitution at this endpoint). |
| body required | string Notification body (already-rendered text). |
| scheduledAt | string <date-time> ISO-8601 timestamp. If omitted, the message is sent as soon as
the dispatcher cron next ticks (typically <60s). For delayed
sends pass a future timestamp; the cron will pick it up only
once |
| validUntil | string or null <date-time> Auto-hide datetime. After this passes the message disappears from
the inbox AND is suppressed from the dispatcher (status flips to
|
| pushEnabled | boolean Default: true When false, the row is created but the dispatcher immediately
marks it |
object Optional structured payload attached to the inbox row. Reserved
keys: | |
| idempotencyKey | string <= 96 characters Per-request idempotency key. Namespaced internally as
|
| createdBy | string <= 64 characters Free-text label persisted in the |
| redirectRoute | string or null <= 128 characters Opaque deep-link string echoed back to the Flutter client in the
FCM |
{- "userIds": [
- 42,
- 137
], - "callerIds": [
- "905322824781",
- "905329876543"
], - "title": "Hoş geldiniz",
- "body": "Yeni özellikleri keşfetmeye hazır mısınız?",
- "scheduledAt": "2026-05-15T10:00:00Z",
- "validUntil": "2019-08-24T14:15:22Z",
- "pushEnabled": true,
- "metadata": { },
- "idempotencyKey": "campaign-2026-04-spring-001",
- "createdBy": "ops-burak",
- "redirectRoute": "paywall:coins_500"
}{- "inserted": 0,
- "skippedDuplicates": 0,
- "skippedUnknown": 0,
- "messageIds": [
- 0
]
}Returns inbox rows across all users, newest first. All filters are AND-combined; omit any to disable. Use this surface to triage pending/failed rows or audit a specific user's history.
| userId | integer <int64> Example: userId=1042 Filter to a single recipient ( |
| ruleId | integer Example: ruleId=7 Filter to messages produced by a specific auto-rule (P2; null on direct sends). |
| status | string Enum: "pending" "delivered" "partial" "failed" "no_token" "suppressed" Example: status=delivered |
| since | string <date-time> Example: since=2026-05-01T00:00:00Z Only return rows whose |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "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"
}
], - "limit": 1,
- "offset": 0
}Inserts ONE row in system_message_broadcast_jobs. The fan-out cron
(bin/sysmsg_broadcast_fanout.php, every minute) pages through users
matching the audience, calls IVR per user for vip/non_vip filtering,
and inserts per-user system_messages rows. Synchronous fan-out is
forbidden — see plan invariant #4.
With an idempotencyKey, a duplicate POST returns 200 + replayed=true
instead of creating a new job.
| audience required | string Enum: "all" "vip" "non_vip" v1 segments. |
| title required | string <= 255 characters Already-rendered notification title. |
| body required | string Already-rendered notification body. |
| scheduledAt | string <date-time> Optional ISO-8601; defaults to now. |
object | |
| idempotencyKey | string <= 96 characters If supplied, a duplicate request returns the existing job (replayed=true). |
| createdBy | string <= 64 characters |
{- "audience": "all",
- "title": "string",
- "body": "string",
- "scheduledAt": "2019-08-24T14:15:22Z",
- "metadata": { },
- "idempotencyKey": "string",
- "createdBy": "string"
}{- "jobId": 0,
- "replayed": true,
- "status": "pending"
}| id required | integer <int64> |
{- "id": 0,
- "userId": 0,
- "templateId": 0,
- "ruleId": 0,
- "title": "string",
- "body": "string",
- "metadata": { },
- "redirectRoute": "string",
- "scheduledAt": "2019-08-24T14:15:22Z",
- "deliveryStatus": "pending",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "readAt": "2019-08-24T14:15:22Z",
- "surfacedAt": "2019-08-24T14:15:22Z",
- "deletedAt": "2019-08-24T14:15:22Z",
- "idempotencyKey": "string",
- "createdAt": "2019-08-24T14:15:22Z"
}Differs from the user-facing soft-delete: this DROPs the row entirely
rather than setting deleted_at. Use sparingly — rows usually stay
forever for audit. Intended for cleaning up errant sends that should
not stay in user inboxes.
| id required | integer <int64> |
{- "deleted": true,
- "read": true,
- "disabled": true
}| id required | integer <int64> |
{- "id": 0,
- "audience": "all",
- "title": "string",
- "body": "string",
- "metadata": { },
- "scheduledAt": "2019-08-24T14:15:22Z",
- "idempotencyKey": "string",
- "cursorUserId": 0,
- "status": "pending",
- "insertedCount": 0,
- "createdBy": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Templates use {{var}} substitution rendered at rule-fire time
(auto-rules, P2). The admin panel is expected to lint placeholder
keys against a known whitelist before save.
| templateKey required | string <= 64 characters Stable key (e.g. ftu_welcome_10min). Unique across templates. |
| titleTemplate required | string <= 255 characters Supports |
| bodyTemplate required | string Supports |
| locale | string <= 8 characters Default: "tr" |
| redirectRoute | string or null <= 128 characters Default deep-link for every message rendered from this template.
Per-instance callers (direct send, announcement create) can
override; rule-fired messages inherit verbatim. Opaque string —
see |
{- "templateKey": "string",
- "titleTemplate": "string",
- "bodyTemplate": "string",
- "locale": "tr",
- "redirectRoute": "avatar_shop"
}{- "id": 0
}| activeOnly | boolean Example: activeOnly=true |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "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"
}
], - "limit": 0,
- "offset": 0
}{- "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"
}| id required | integer |
| titleTemplate | string <= 255 characters |
| bodyTemplate | string |
| locale | string <= 8 characters |
| isActive | boolean |
| redirectRoute | string or null <= 128 characters Pass null to clear the template default. |
{- "titleTemplate": "string",
- "bodyTemplate": "string",
- "locale": "string",
- "isActive": true,
- "redirectRoute": "string"
}{- "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"
}Hard delete is intentionally not supported. Existing
system_messages.template_id rows would dangle. To "remove" a
template, soft-disable it; rules referencing it stop firing.
| id required | integer |
{- "deleted": true,
- "read": true,
- "disabled": true
}Rules tie a template to a trigger event + audience. The rule engine
renders the template at fire time and inserts a system_messages
row for each matched user. Idempotency on
(rule_id, user_id, fired_period) — the engine cannot double-fire.
| ruleKey required | string <= 64 characters Stable handle for ops debugging. Unique across rules. |
| templateId required | integer Reference to an active |
| triggerEvent required | string Enum: "user_registered" "user_first_login" "user_returned_after_idle" "scheduled_recurring"
|
object or null Per-event params. Required for idle/recurring; optional for
inline events (kept null typically). Examples:
| |
object or null
| |
| delaySeconds | integer >= 0 Default: 0 Applied at fire time. |
| targetKind | string Default: "personal" Enum: "personal" "announcement"
|
{- "ruleKey": "string",
- "templateId": 0,
- "triggerEvent": "user_registered",
- "triggerParams": { },
- "audienceFilter": { },
- "delaySeconds": 0,
- "targetKind": "personal"
}{- "id": 0
}| activeOnly | boolean Example: activeOnly=true |
| limit | integer [ 1 .. 200 ] Default: 30 Example: limit=30 |
| offset | integer >= 0 Default: 0 Example: offset=0 |
{- "items": [
- {
- "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"
}
], - "limit": 0,
- "offset": 0
}{- "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"
}| id required | integer |
| templateId | integer |
| triggerEvent | string Enum: "user_registered" "user_first_login" "user_returned_after_idle" "scheduled_recurring" |
object or null | |
object or null | |
| delaySeconds | integer >= 0 |
| isActive | boolean |
| targetKind | string Enum: "personal" "announcement" See create-request notes. Switching an active rule from
|
{- "templateId": 0,
- "triggerEvent": "user_registered",
- "triggerParams": { },
- "audienceFilter": { },
- "delaySeconds": 0,
- "isActive": true,
- "targetKind": "personal"
}{- "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"
}Hard delete is intentionally not supported. Existing
system_messages.rule_id rows would dangle.
| id required | integer |
{- "deleted": true,
- "read": true,
- "disabled": true
}Single source of truth for the placeholder keys available to admin- authored templates. The admin panel consumes this to render chip- based variable pickers (Templates / Compose / Automations) and to warn when a template references a key that the chosen trigger does not supply.
Scopes:
global — always available (date, weekday, month, year).user — available wherever the renderer knows a recipient.trigger— only injected when a rule with that trigger fires.{- "items": [
- {
- "key": "string",
- "label": "string",
- "description": "string",
- "scope": "global",
- "trigger": "user_registered",
- "sample": "string"
}
], - "byScope": {
- "global": [
- {
- "key": "string",
- "label": "string",
- "description": "string",
- "scope": "global",
- "trigger": "user_registered",
- "sample": "string"
}
], - "user": [
- {
- "key": "string",
- "label": "string",
- "description": "string",
- "scope": "global",
- "trigger": "user_registered",
- "sample": "string"
}
], - "trigger": [
- {
- "key": "string",
- "label": "string",
- "description": "string",
- "scope": "global",
- "trigger": "user_registered",
- "sample": "string"
}
]
}, - "byTrigger": {
- "property1": [
- "string"
], - "property2": [
- "string"
]
}, - "triggers": [
- {
- "key": "user_registered",
- "label": "string",
- "description": "string",
- "fires": "string",
- "params": [
- {
- "name": "string",
- "type": "int",
- "label": "string",
- "required": true,
- "min": 0,
- "max": 0,
- "default": null,
- "help": "string",
- "options": [
- {
- "value": "string",
- "label": "string"
}
]
}
], - "providesVars": [
- "string"
], - "supportsTargetKind": [
- "personal"
]
}
], - "personas": [
- {
- "id": "string",
- "label": "string",
- "description": "string",
- "appliesToTriggers": [
- "string"
], - "context": { }
}
], - "segments": [
- {
- "value": "all",
- "label": "string",
- "description": "string"
}
]
}Authors a broadcast definition with a validity window, audience
filter, and optional FCM push toggle. Per-user inbox state is
materialized lazily (catchup on inbox fetch) and eagerly (dispatcher
at scheduled_at). New users registering after scheduled_at see
the message on their first inbox fetch as long as valid_until has
not passed.
Idempotency: pass idempotencyKey; re-submission returns the
existing announcement (replayed=true) instead of duplicating.
| audienceType required | string Enum: "all" "vip" "non_vip" "specific_users"
|
| title | string <= 255 characters Push banner title. Inbox sender label is a separate |
| body | string <= 2000 characters |
| scheduledAt | string <date-time> Visibility gate. The announcement is invisible in inboxes AND no push fires until this time. Defaults to NOW() when omitted. |
| validUntil | string or null <date-time> Auto-hide datetime. After this passes the announcement disappears from every inbox (recipients and never-seen users alike). Omit or send null for "never expires". |
| pushEnabled | boolean Default: true When false, the message appears in the in-app inbox but no FCM banner is fired. |
object or null | |
| recipientUserIds | Array of integers Required when |
| recipientCallerIds | Array of strings Digits-only callerids; resolved server-side to user ids. Unknown ids are silently dropped. |
| idempotencyKey | string <= 128 characters Re-submitting with the same key returns the original announcement instead of creating a duplicate. |
| createdBy | string <= 64 characters Free-text admin label for audit; defaults to |
| templateId | integer or null <int64> FK to |
object or null Static context map merged into the per-recipient render context
(admin/rule-supplied). User-specific keys like | |
| redirectRoute | string or null <= 128 characters Per-blast deep-link override echoed back to the Flutter client in
the FCM |
{- "audienceType": "all",
- "title": "string",
- "body": "string",
- "scheduledAt": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z",
- "pushEnabled": true,
- "metadata": { },
- "recipientUserIds": [
- 0
], - "recipientCallerIds": [
- "string"
], - "idempotencyKey": "string",
- "createdBy": "string",
- "templateId": 0,
- "variables": { },
- "redirectRoute": "event:halloween_2026"
}{- "announcementId": 0,
- "replayed": true
}| audienceType | string Enum: "all" "vip" "non_vip" "specific_users" |
| pushStatus | string Enum: "pending" "dispatching" "completed" "disabled" |
| since | string <date-time> Filter to rows with |
| activeOnly | string Value: "1" Pass |
| limit | integer [ 1 .. 200 ] Default: 30 |
| offset | integer >= 0 Default: 0 |
{- "items": [
- {
- "id": 0,
- "title": "string",
- "body": "string",
- "metadata": { },
- "redirect_route": "string",
- "audience_type": "all",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "valid_until": "2019-08-24T14:15:22Z",
- "push_enabled": 0,
- "push_status": "pending",
- "push_cursor_user_id": 0,
- "idempotency_key": "string",
- "created_by": "string",
- "cancelled_at": "2019-08-24T14:15:22Z",
- "cancelled_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "push_status_counts": {
- "property1": 0,
- "property2": 0
}, - "recipient_user_ids": [
- 0
]
}
], - "limit": 0,
- "offset": 0
}| id required | integer <int64> |
{- "id": 0,
- "title": "string",
- "body": "string",
- "metadata": { },
- "redirect_route": "string",
- "audience_type": "all",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "valid_until": "2019-08-24T14:15:22Z",
- "push_enabled": 0,
- "push_status": "pending",
- "push_cursor_user_id": 0,
- "idempotency_key": "string",
- "created_by": "string",
- "cancelled_at": "2019-08-24T14:15:22Z",
- "cancelled_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "push_status_counts": {
- "property1": 0,
- "property2": 0
}, - "recipient_user_ids": [
- 0
]
}Use this to extend a promo window mid-flight, or to kill an in-flight
push (pushEnabled=false flips push_status to disabled and stops
the dispatcher from claiming new state rows).
| id required | integer <int64> |
| validUntil | string or null <date-time> Send null to clear (never expires). |
| pushEnabled | boolean Setting this to false also flips push_status to |
| redirectRoute | string or null <= 128 characters Send null to clear. Affects only pushes fired after the patch — already-dispatched rows kept their stored value. |
{- "validUntil": "2019-08-24T14:15:22Z",
- "pushEnabled": true,
- "redirectRoute": "string"
}{- "id": 0,
- "title": "string",
- "body": "string",
- "metadata": { },
- "redirect_route": "string",
- "audience_type": "all",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "valid_until": "2019-08-24T14:15:22Z",
- "push_enabled": 0,
- "push_status": "pending",
- "push_cursor_user_id": 0,
- "idempotency_key": "string",
- "created_by": "string",
- "cancelled_at": "2019-08-24T14:15:22Z",
- "cancelled_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "push_status_counts": {
- "property1": 0,
- "property2": 0
}, - "recipient_user_ids": [
- 0
]
}First-class cancellation. Stamps cancelled_at / cancelled_by for
audit, sets valid_until=NOW() (hides the message from every inbox),
and push_enabled=0, push_status=disabled (stops the dispatcher).
Idempotent. Re-cancelling an already-cancelled row returns the same
snapshot without changing cancelled_at.
Rejected with ALREADY_COMPLETED once push_status='completed' —
once the broadcast has fully fanned out and pushed, use
PATCH /admin/announcements/{id} with validUntil instead to
retroactively hide the inbox row.
| id required | integer <int64> |
| cancelledBy | string Identifier of the operator performing the cancel; defaults to "admin". |
{- "cancelledBy": "ops-berkk"
}{- "id": 0,
- "title": "string",
- "body": "string",
- "metadata": { },
- "redirect_route": "string",
- "audience_type": "all",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "valid_until": "2019-08-24T14:15:22Z",
- "push_enabled": 0,
- "push_status": "pending",
- "push_cursor_user_id": 0,
- "idempotency_key": "string",
- "created_by": "string",
- "cancelled_at": "2019-08-24T14:15:22Z",
- "cancelled_by": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "push_status_counts": {
- "property1": 0,
- "property2": 0
}, - "recipient_user_ids": [
- 0
]
}Read-only aggregation over user_announcement_state for the given
announcement. Returns the recipient → surfaced → opened funnel plus
push-status splits and time-to-read percentiles.
recipients counts state rows materialized so far — eagerly via the
push dispatcher's Phase 1 fan-out, and lazily via the catchup service
on each inbox open. For broad audiences (all / vip / non_vip)
this grows over time as users open their inbox, so openRate is "of
people reached so far" not "of the total addressable audience".
surfaced is the count of rows whose surfaced_at is set — i.e. the
row was returned by the inbox-list endpoint at least once. opened
is the count with read_at set (user explicitly tapped the message
in the thread page).
p50MinutesToRead / p90MinutesToRead are percentiles of
(read_at - COALESCE(surfaced_at, first_seen_at)) in whole minutes,
computed only over rows where read_at IS NOT NULL. Null when no
opens have happened yet.
| id required | integer <int64> |
{- "announcementId": 0,
- "audienceType": "all",
- "scheduledAt": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z",
- "pushEnabled": true,
- "pushStatus": "pending",
- "cancelledAt": "2019-08-24T14:15:22Z",
- "funnel": {
- "recipients": 0,
- "surfaced": 0,
- "pushSent": 0,
- "pushFailed": 0,
- "pushNoToken": 0,
- "pushPending": 0,
- "opened": 0,
- "openRate": 0.1,
- "p50MinutesToRead": 0.1,
- "p90MinutesToRead": 0.1
}
}User-facing inbox for admin/system messages. Rendered pinned at the top of the messaging screen, separate from Zego DMs. Soft-delete is per-user; admin force-delete is a separate hard delete.
Returns the calling user's inbox rows. The Flutter messaging screen
renders these pinned at the top, separate from Zego DMs. Soft-deleted
rows (DELETE /api/system-messages/{kind}/{id}) are filtered out.
Each row carries a kind discriminator: personal (per-user
system_messages rows) or announcement (broadcast definitions
with lazy per-user state — new users register-after-send still
catch up to currently-valid announcements on first inbox fetch).
Order defaults to ASC by scheduledAt (target visibility time, not
row creation time). Pass direction=desc to flip.
| 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. |
{- "items": [
- {
- "kind": "personal",
- "id": 0,
- "title": "string",
- "pushTitle": "string",
- "body": "string",
- "metadata": { },
- "scheduledAt": "2019-08-24T14:15:22Z",
- "pushEnabled": true,
- "deliveryStatus": "pending",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "readAt": "2019-08-24T14:15:22Z"
}
], - "limit": 1,
- "offset": 0,
- "direction": "asc"
}Returns one row representing the user's entire SYSTEM messagebox —
live inbox title (resolved from system_config, NOT frozen onto the
row), live unread count, and the newest deliverable inbox row's body
hasMessages: false means the user has zero deliverable rows
(empty inbox, or every row soft-deleted / suppressed / permanent
failed); the client should hide the SYSTEM tile entirely.
{- "title": "[SYSTEM]",
- "unreadCount": 0,
- "hasMessages": true,
- "lastMessage": "string",
- "lastAt": "2019-08-24T14:15:22Z",
- "lastId": 0
}Idempotent — re-marking an already-read row is a no-op.
kind is one of personal (system_messages row) or announcement
(user_announcement_state row keyed on the broadcast definition).
IDs are namespaced per kind.
| kind required | string Enum: "personal" "announcement" |
| id required | integer <int64> |
{- "deleted": true,
- "read": true,
- "disabled": true
}kind is one of personal or announcement — see markRead.
| kind required | string Enum: "personal" "announcement" |
| id required | integer <int64> |
{- "deleted": true,
- "read": true,
- "disabled": true
}