Represents a user in the system.
| Field | Type | Description |
|---|---|---|
id | integer | User ID |
email | string | User email address (hidden for limited access) |
phone | string | User phone number (hidden for limited access) |
bank_account_number | string|null | Bank account number (hidden for limited access) |
trainer_license_image | string|null | Filename of the uploaded trainer license document (hidden for limited access) |
trainer_license_link | string|null | Download path for the license document, or null when none is uploaded (hidden for limited access) |
trainer_license_approved | boolean|null | null = pending review, true = approved, false = rejected (hidden for limited access) |
trainer_license_rejection_reason | string|null | Operator's reason when rejected (hidden for limited access) |
username | string | Username |
first_name | string | First name |
last_name | string | Last name |
created_at | string (date) | Account creation timestamp |
referral_code | string | User's referral code |
referrer_user_id | integer|null | Referring user ID, if any (hidden for limited access) |
height | integer|null | User's height in CM |
birth_date | string (date)|null | User's birth date |
gender | string|null | User's gender (male, female, other) |
is_trainer | boolean | The user's own statement that they coach. Self-identification for client navigation only — authorizes nothing |
is_gym_owner | boolean | The user's own statement that they run a gym. Self-identification for client navigation only — authorizes nothing |
referral_score | number | User's weighted referral score — a float, not an integer (hidden for limited access). See Referral Score |
referrals_count | integer | How many users this user directly referred (hidden for limited access) |
roles | array | List of Role Resource (hidden for limited access) |
deletion_requested_at | string (date)|null | When the user asked for their own account to be deleted, or null (hidden for limited access) |
deletion_scheduled_at | string (date)|null | When the account is due to be disposed of, or null (hidden for limited access) |
media | array | List of Media Resource. Excludes purpose-bearing rows such as the trainer license |
current_subscription | object|null | User Premium Subscription Resource |
Self-identification flags: is_trainer and is_gym_owner say how the user describes themself so the clients can hide sections that do not apply. They are not authorization and no policy, permission or route middleware reads them — a user who sets is_trainer to false keeps every permission, training service and trainee they had. They are visible to every viewer for the same reason: they shape navigation, not access. They are settable only on PUT /auth/me; PUT /users/{id} deliberately ignores them.
Pending deletion: deletion_requested_at and deletion_scheduled_at are non-null only while a self-service account deletion is pending — see POST /api/v1/auth/delete-account. That an account is on its way out is the subject's own business and an operator's, which is why the pair sits behind the same gate as email and phone. Neither field is writable by any endpoint. An account in this state is also omitted from GET /api/v1/users and from training-service search for every viewer without users.view_all, so in practice a limited-access viewer never encounters one.
Trainer license: the document is identity PII. It is never attached to the public media array, and trainer_license_link points at the standard media download route, which refuses to serve purpose-bearing media to anyone but the subject and holders of users.view_all. See Download Media.
Note: Fields marked with (hidden for limited access) are only included when the viewer is the user themself or an authenticated user with the users.view_all permission. All other viewers — including unauthenticated guests (e.g. on public endpoints that embed a user, such as the training services list) and users with only users.view_limited — do not receive these fields.
{
"id": 123,
"email": "test@example.com",
"phone": "+12345678",
"bank_account_number": "IR123456789012345678901234",
"trainer_license_image": "9f2c1b7e4a.jpg",
"trainer_license_link": "/api/v1/media/456/9f2c1b7e4a.jpg",
"trainer_license_approved": true,
"trainer_license_rejection_reason": null,
"username": "test_username",
"first_name": "John",
"last_name": "Doe",
"created_at": "1970-01-01 00:00:00",
"referral_code": "XFER543D4...",
"referrer_user_id": null,
"height": 180,
"birth_date": "1990-01-01",
"gender": "male",
"is_trainer": false,
"is_gym_owner": false,
"referral_score": 1.5,
"referrals_count": 1,
"roles": [<role resource>, ...],
"deletion_requested_at": null,
"deletion_scheduled_at": null,
"media": [<media resource>, ...],
"current_subscription": <user premium subscription resource>
}
This number is calculated based on the number of people that this user has invited to the platform + half of their score too.
So if we have a tree like this:
These are gonna be the scores:
The depth has no limit.
It is a float, and always a JSON number — never a quoted string. The value is cached server-side for ten hours;
the cast that keeps the type stable across a cache hit and a cache miss lives in UserResource.
1 + (referee_score / 2) summed over direct referrals produces values like 1.5; the API emits the number
unrounded. Current client behaviour is to display it to one decimal place and not to round server-side values
themselves. That is not yet a signed-off product rule — it is what the apps do today, recorded here so they stay
consistent with each other.
referral_score vs. referrals_countreferrals_count is the plain number of users whose referrer_user_id is this user — direct invitations only, no
weighting and no recursion. referral_score weights the whole tree beneath them. A user who invited three people,
one of whom invited someone else, has referrals_count: 3 and referral_score: 3.5.
The two figures can briefly disagree: referrals_count is read live on every request, while referral_score is
cached for ten hours, so a freshly-referred user shows up in the count up to ten hours before the score moves. The
count is deliberately not put behind the same cache — one indexed COUNT on a foreign key is cheaper than a second
invalidation path is to get right.